Merge branch 'feature/story-28-ui-show-test-todos'

This commit is contained in:
Dave
2026-02-19 15:36:04 +00:00
13 changed files with 606 additions and 17 deletions

View File

@@ -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: [] }),
}, },
}; };
}); });

View File

@@ -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);
},
}; };

View File

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

View File

@@ -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>

View File

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

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

View File

@@ -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,

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

View File

@@ -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: [] } }),
),
]); ]);
} }

View File

@@ -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: [] } }),
),
]); ]);
} }

View File

@@ -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(&current_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(

View File

@@ -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"]);
}
} }