Merge branch 'feature/story-28-ui-show-test-todos'
This commit is contained in:
@@ -43,6 +43,9 @@ vi.mock("./api/workflow", () => {
|
|||||||
recordTests: vi.fn(),
|
recordTests: vi.fn(),
|
||||||
ensureAcceptance: vi.fn(),
|
ensureAcceptance: vi.fn(),
|
||||||
getReviewQueue: 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[];
|
stories: ReviewStory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoryTodosResponse {
|
||||||
|
story_id: string;
|
||||||
|
story_name: string | null;
|
||||||
|
todos: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoListResponse {
|
||||||
|
stories: StoryTodosResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_API_BASE = "/api";
|
const DEFAULT_API_BASE = "/api";
|
||||||
|
|
||||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||||
@@ -131,4 +141,7 @@ export const workflowApi = {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getStoryTodos(baseUrl?: string) {
|
||||||
|
return requestJson<TodoListResponse>("/workflow/todos", {}, baseUrl);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ vi.mock("../api/workflow", () => {
|
|||||||
ensureAcceptance: vi.fn(),
|
ensureAcceptance: vi.fn(),
|
||||||
recordCoverage: vi.fn(),
|
recordCoverage: vi.fn(),
|
||||||
collectCoverage: vi.fn(),
|
collectCoverage: vi.fn(),
|
||||||
|
getStoryTodos: vi.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -54,6 +55,7 @@ const mockedWorkflow = {
|
|||||||
getReviewQueue: vi.mocked(workflowApi.getReviewQueue),
|
getReviewQueue: vi.mocked(workflowApi.getReviewQueue),
|
||||||
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll),
|
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll),
|
||||||
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance),
|
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance),
|
||||||
|
getStoryTodos: vi.mocked(workflowApi.getStoryTodos),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Chat review panel", () => {
|
describe("Chat review panel", () => {
|
||||||
@@ -75,6 +77,7 @@ describe("Chat review panel", () => {
|
|||||||
});
|
});
|
||||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||||
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||||
|
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an empty review queue state", async () => {
|
it("shows an empty review queue state", async () => {
|
||||||
@@ -510,4 +513,57 @@ describe("Chat review panel", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText(/Coverage: 85\.0%/)).toBeInTheDocument();
|
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 { ChatHeader } from "./ChatHeader";
|
||||||
import { GatePanel } from "./GatePanel";
|
import { GatePanel } from "./GatePanel";
|
||||||
import { ReviewPanel } from "./ReviewPanel";
|
import { ReviewPanel } from "./ReviewPanel";
|
||||||
|
import { TodoPanel } from "./TodoPanel";
|
||||||
|
|
||||||
const { useCallback, useEffect, useRef, useState } = React;
|
const { useCallback, useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
@@ -61,6 +62,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null);
|
const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null);
|
||||||
const [isCollectingCoverage, setIsCollectingCoverage] = useState(false);
|
const [isCollectingCoverage, setIsCollectingCoverage] = useState(false);
|
||||||
const [coverageError, setCoverageError] = useState<string | null>(null);
|
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 storyId = "26_establish_tdd_workflow_and_gates";
|
||||||
const gateStatusColor = isGateLoading
|
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) => {
|
const refreshGateState = async (targetStoryId: string = storyId) => {
|
||||||
setIsGateLoading(true);
|
setIsGateLoading(true);
|
||||||
setGateError(null);
|
setGateError(null);
|
||||||
@@ -599,6 +668,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
onCollectCoverage={handleCollectCoverage}
|
onCollectCoverage={handleCollectCoverage}
|
||||||
isCollectingCoverage={isCollectingCoverage}
|
isCollectingCoverage={isCollectingCoverage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TodoPanel
|
||||||
|
todos={storyTodos}
|
||||||
|
isTodoLoading={isTodoLoading}
|
||||||
|
todoError={todoError}
|
||||||
|
lastTodoRefresh={lastTodoRefresh}
|
||||||
|
onRefresh={refreshTodos}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ const baseProps = {
|
|||||||
gateStatusColor: "#aaa",
|
gateStatusColor: "#aaa",
|
||||||
isGateLoading: false,
|
isGateLoading: false,
|
||||||
gateError: null,
|
gateError: null,
|
||||||
|
coverageError: null,
|
||||||
lastGateRefresh: null,
|
lastGateRefresh: null,
|
||||||
onRefresh: vi.fn(),
|
onRefresh: vi.fn(),
|
||||||
|
onCollectCoverage: vi.fn(),
|
||||||
|
isCollectingCoverage: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("GatePanel", () => {
|
describe("GatePanel", () => {
|
||||||
@@ -21,9 +24,7 @@ describe("GatePanel", () => {
|
|||||||
|
|
||||||
it("shows loading message when isGateLoading is true", () => {
|
it("shows loading message when isGateLoading is true", () => {
|
||||||
render(<GatePanel {...baseProps} isGateLoading={true} />);
|
render(<GatePanel {...baseProps} isGateLoading={true} />);
|
||||||
expect(
|
expect(screen.getByText("Loading workflow gates...")).toBeInTheDocument();
|
||||||
screen.getByText("Loading workflow gates..."),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error with retry button", async () => {
|
it("shows error with retry button", async () => {
|
||||||
@@ -64,13 +65,12 @@ describe("GatePanel", () => {
|
|||||||
warning: null,
|
warning: null,
|
||||||
summary: { total: 5, passed: 5, failed: 0 },
|
summary: { total: 5, passed: 5, failed: 0 },
|
||||||
missingCategories: [],
|
missingCategories: [],
|
||||||
|
coverageReport: null,
|
||||||
}}
|
}}
|
||||||
gateStatusLabel="Ready to accept"
|
gateStatusLabel="Ready to accept"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(screen.getByText(/5\/5 passing, 0 failing/)).toBeInTheDocument();
|
||||||
screen.getByText(/5\/5 passing, 0 failing/),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows missing categories", () => {
|
it("shows missing categories", () => {
|
||||||
@@ -83,12 +83,11 @@ describe("GatePanel", () => {
|
|||||||
warning: null,
|
warning: null,
|
||||||
summary: { total: 0, passed: 0, failed: 0 },
|
summary: { total: 0, passed: 0, failed: 0 },
|
||||||
missingCategories: ["unit", "integration"],
|
missingCategories: ["unit", "integration"],
|
||||||
|
coverageReport: null,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument();
|
||||||
screen.getByText("Missing: unit, integration"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows warning text", () => {
|
it("shows warning text", () => {
|
||||||
@@ -101,6 +100,7 @@ describe("GatePanel", () => {
|
|||||||
warning: "Multiple tests failing — fix one at a time.",
|
warning: "Multiple tests failing — fix one at a time.",
|
||||||
summary: { total: 4, passed: 2, failed: 2 },
|
summary: { total: 4, passed: 2, failed: 2 },
|
||||||
missingCategories: [],
|
missingCategories: [],
|
||||||
|
coverageReport: null,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -119,12 +119,11 @@ describe("GatePanel", () => {
|
|||||||
warning: null,
|
warning: null,
|
||||||
summary: { total: 2, passed: 1, failed: 1 },
|
summary: { total: 2, passed: 1, failed: 1 },
|
||||||
missingCategories: [],
|
missingCategories: [],
|
||||||
|
coverageReport: null,
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(screen.getByText("No approved test plan.")).toBeInTheDocument();
|
||||||
screen.getByText("No approved test plan."),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Tests are failing.")).toBeInTheDocument();
|
expect(screen.getByText("Tests are failing.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,9 +131,7 @@ describe("GatePanel", () => {
|
|||||||
const onRefresh = vi.fn();
|
const onRefresh = vi.fn();
|
||||||
render(<GatePanel {...baseProps} onRefresh={onRefresh} />);
|
render(<GatePanel {...baseProps} onRefresh={onRefresh} />);
|
||||||
|
|
||||||
await userEvent.click(
|
await userEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
screen.getByRole("button", { name: "Refresh" }),
|
|
||||||
);
|
|
||||||
expect(onRefresh).toHaveBeenCalledOnce();
|
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 { 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 type { FileEntry } from "./usePathCompletion";
|
||||||
import {
|
import {
|
||||||
getCurrentPartial,
|
getCurrentPartial,
|
||||||
|
|||||||
166
frontend/tests/e2e/story-todos.spec.ts
Normal file
166
frontend/tests/e2e/story-todos.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import type {
|
||||||
|
AcceptanceResponse,
|
||||||
|
ReviewListResponse,
|
||||||
|
TodoListResponse,
|
||||||
|
} from "../../src/api/workflow";
|
||||||
|
|
||||||
|
function mockChatApis(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
overrides: {
|
||||||
|
acceptance?: AcceptanceResponse;
|
||||||
|
reviewQueue?: ReviewListResponse;
|
||||||
|
todos?: TodoListResponse;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const acceptance: AcceptanceResponse = overrides.acceptance ?? {
|
||||||
|
can_accept: false,
|
||||||
|
reasons: ["No test results recorded for the story."],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 0, passed: 0, failed: 0 },
|
||||||
|
missing_categories: ["unit", "integration"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewQueue: ReviewListResponse = overrides.reviewQueue ?? {
|
||||||
|
stories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const todos: TodoListResponse = overrides.todos ?? {
|
||||||
|
stories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
page.route("**/api/projects", (route) =>
|
||||||
|
route.fulfill({ json: ["/tmp/test-project"] }),
|
||||||
|
),
|
||||||
|
page.route("**/api/io/fs/home", (route) =>
|
||||||
|
route.fulfill({ json: "/tmp" }),
|
||||||
|
),
|
||||||
|
page.route("**/api/project", (route) => {
|
||||||
|
if (route.request().method() === "POST") {
|
||||||
|
return route.fulfill({ json: "/tmp/test-project" });
|
||||||
|
}
|
||||||
|
if (route.request().method() === "DELETE") {
|
||||||
|
return route.fulfill({ json: true });
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: null });
|
||||||
|
}),
|
||||||
|
page.route("**/api/ollama/models**", (route) =>
|
||||||
|
route.fulfill({ json: ["llama3.1"] }),
|
||||||
|
),
|
||||||
|
page.route("**/api/anthropic/key/exists", (route) =>
|
||||||
|
route.fulfill({ json: false }),
|
||||||
|
),
|
||||||
|
page.route("**/api/anthropic/models", (route) =>
|
||||||
|
route.fulfill({ json: [] }),
|
||||||
|
),
|
||||||
|
page.route("**/api/model", (route) => {
|
||||||
|
if (route.request().method() === "POST") {
|
||||||
|
return route.fulfill({ json: true });
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: null });
|
||||||
|
}),
|
||||||
|
page.route("**/api/workflow/acceptance", (route) => {
|
||||||
|
if (route.request().url().includes("/ensure")) return route.fallback();
|
||||||
|
return route.fulfill({ json: acceptance });
|
||||||
|
}),
|
||||||
|
page.route("**/api/workflow/review/all", (route) =>
|
||||||
|
route.fulfill({ json: reviewQueue }),
|
||||||
|
),
|
||||||
|
page.route("**/api/workflow/acceptance/ensure", (route) =>
|
||||||
|
route.fulfill({ json: true }),
|
||||||
|
),
|
||||||
|
page.route("**/api/io/fs/list/absolute**", (route) =>
|
||||||
|
route.fulfill({ json: [] }),
|
||||||
|
),
|
||||||
|
page.route("**/api/workflow/todos", (route) =>
|
||||||
|
route.fulfill({ json: todos }),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProject(page: import("@playwright/test").Page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByPlaceholder("/path/to/project").fill("/tmp/test-project");
|
||||||
|
await page.getByRole("button", { name: "Open Project" }).click();
|
||||||
|
await expect(page.getByText("Story TODOs", { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Story TODOs panel", () => {
|
||||||
|
test("shows unchecked acceptance criteria", async ({ page }) => {
|
||||||
|
await mockChatApis(page, {
|
||||||
|
todos: {
|
||||||
|
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.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await openProject(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText("The UI lists unchecked acceptance criteria."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Each TODO is displayed as its full text."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("2 remaining")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows completion message when all criteria are checked", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockChatApis(page, {
|
||||||
|
todos: {
|
||||||
|
stories: [
|
||||||
|
{
|
||||||
|
story_id: "28_ui_show_test_todos",
|
||||||
|
story_name: "Show Remaining Test TODOs in the UI",
|
||||||
|
todos: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await openProject(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText("All acceptance criteria complete."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("0 remaining")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows TODO items from multiple stories", async ({ page }) => {
|
||||||
|
await mockChatApis(page, {
|
||||||
|
todos: {
|
||||||
|
stories: [
|
||||||
|
{
|
||||||
|
story_id: "28_ui_show_test_todos",
|
||||||
|
story_name: "Show TODOs",
|
||||||
|
todos: ["First criterion."],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
story_id: "29_another_story",
|
||||||
|
story_name: "Another Story",
|
||||||
|
todos: ["Second criterion."],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await openProject(page);
|
||||||
|
|
||||||
|
await expect(page.getByText("First criterion.")).toBeVisible();
|
||||||
|
await expect(page.getByText("Second criterion.")).toBeVisible();
|
||||||
|
await expect(page.getByText("2 remaining")).toBeVisible();
|
||||||
|
await expect(page.getByText("Show TODOs")).toBeVisible();
|
||||||
|
await expect(page.getByText("Another Story")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,9 @@ function mockChatApis(
|
|||||||
page.route("**/api/io/fs/list/absolute**", (route) =>
|
page.route("**/api/io/fs/list/absolute**", (route) =>
|
||||||
route.fulfill({ json: [] }),
|
route.fulfill({ json: [] }),
|
||||||
),
|
),
|
||||||
|
page.route("**/api/workflow/todos", (route) =>
|
||||||
|
route.fulfill({ json: { stories: [] } }),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ function mockChatApis(
|
|||||||
page.route("**/api/io/fs/list/absolute**", (route) =>
|
page.route("**/api/io/fs/list/absolute**", (route) =>
|
||||||
route.fulfill({ json: [] }),
|
route.fulfill({ json: [] }),
|
||||||
),
|
),
|
||||||
|
page.route("**/api/workflow/todos", (route) =>
|
||||||
|
route.fulfill({ json: { stories: [] } }),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||||
use crate::io::story_metadata::{StoryMetadata, parse_front_matter};
|
use crate::io::story_metadata::{StoryMetadata, parse_front_matter, parse_unchecked_todos};
|
||||||
use crate::workflow::{
|
use crate::workflow::{
|
||||||
CoverageReport, StoryTestResults, TestCaseResult, TestStatus,
|
CoverageReport, StoryTestResults, TestCaseResult, TestStatus,
|
||||||
evaluate_acceptance_with_coverage, parse_coverage_json, summarize_results,
|
evaluate_acceptance_with_coverage, parse_coverage_json, summarize_results,
|
||||||
@@ -87,6 +87,18 @@ struct ReviewListResponse {
|
|||||||
pub stories: Vec<ReviewStory>,
|
pub stories: Vec<ReviewStory>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Object)]
|
||||||
|
struct StoryTodosResponse {
|
||||||
|
pub story_id: String,
|
||||||
|
pub story_name: Option<String>,
|
||||||
|
pub todos: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Object)]
|
||||||
|
struct TodoListResponse {
|
||||||
|
pub stories: Vec<StoryTodosResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
|
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
|
||||||
let root = ctx.state.get_project_root()?;
|
let root = ctx.state.get_project_root()?;
|
||||||
let current_dir = root.join(".story_kit").join("stories").join("current");
|
let current_dir = root.join(".story_kit").join("stories").join("current");
|
||||||
@@ -403,6 +415,51 @@ impl WorkflowApi {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List unchecked acceptance criteria (TODOs) for all current stories.
|
||||||
|
#[oai(path = "/workflow/todos", method = "get")]
|
||||||
|
async fn story_todos(&self) -> OpenApiResult<Json<TodoListResponse>> {
|
||||||
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
|
let current_dir = root.join(".story_kit").join("stories").join("current");
|
||||||
|
|
||||||
|
if !current_dir.exists() {
|
||||||
|
return Ok(Json(TodoListResponse {
|
||||||
|
stories: Vec::new(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stories = Vec::new();
|
||||||
|
let mut entries: Vec<_> = fs::read_dir(¤t_dir)
|
||||||
|
.map_err(|e| bad_request(format!("Failed to read current stories: {e}")))?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.collect();
|
||||||
|
entries.sort_by_key(|e| e.file_name());
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let story_id = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let contents = fs::read_to_string(&path)
|
||||||
|
.map_err(|e| bad_request(format!("Failed to read {}: {e}", path.display())))?;
|
||||||
|
let story_name = parse_front_matter(&contents)
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.name);
|
||||||
|
let todos = parse_unchecked_todos(&contents);
|
||||||
|
stories.push(StoryTodosResponse {
|
||||||
|
story_id,
|
||||||
|
story_name,
|
||||||
|
todos,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(TodoListResponse { stories }))
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensure a story can be accepted; returns an error when gates fail.
|
/// Ensure a story can be accepted; returns an error when gates fail.
|
||||||
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
|
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
|
||||||
async fn ensure_acceptance(
|
async fn ensure_acceptance(
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
|
||||||
|
contents
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
trimmed
|
||||||
|
.strip_prefix("- [ ] ")
|
||||||
|
.map(|text| text.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_test_plan_status(value: &str) -> TestPlanStatus {
|
fn parse_test_plan_status(value: &str) -> TestPlanStatus {
|
||||||
match value {
|
match value {
|
||||||
"approved" => TestPlanStatus::Approved,
|
"approved" => TestPlanStatus::Approved,
|
||||||
@@ -108,4 +120,31 @@ workflow: tdd
|
|||||||
Err(StoryMetaError::InvalidFrontMatter(_))
|
Err(StoryMetaError::InvalidFrontMatter(_))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unchecked_todos_mixed() {
|
||||||
|
let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_unchecked_todos(input),
|
||||||
|
vec!["First thing", "Second thing"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unchecked_todos_all_checked() {
|
||||||
|
let input = "- [x] Done\n- [x] Also done\n";
|
||||||
|
assert!(parse_unchecked_todos(input).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unchecked_todos_no_checkboxes() {
|
||||||
|
let input = "# Story\nSome text\n- A bullet\n";
|
||||||
|
assert!(parse_unchecked_todos(input).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unchecked_todos_leading_whitespace() {
|
||||||
|
let input = " - [ ] Indented item\n";
|
||||||
|
assert_eq!(parse_unchecked_todos(input), vec!["Indented item"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user