2026-02-19 12:54:04 +00:00
|
|
|
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
|
|
|
|
use crate::io::story_metadata::{StoryMetadata, parse_front_matter};
|
|
|
|
|
use crate::workflow::{
|
2026-02-19 14:45:57 +00:00
|
|
|
CoverageReport, StoryTestResults, TestCaseResult, TestStatus,
|
|
|
|
|
evaluate_acceptance_with_coverage, parse_coverage_json, summarize_results,
|
2026-02-19 12:54:04 +00:00
|
|
|
};
|
|
|
|
|
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
|
|
|
|
use serde::Deserialize;
|
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
#[derive(Tags)]
|
|
|
|
|
enum WorkflowTags {
|
|
|
|
|
Workflow,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Object)]
|
|
|
|
|
struct TestCasePayload {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub status: String,
|
|
|
|
|
pub details: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Object)]
|
|
|
|
|
struct RecordTestsPayload {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
pub unit: Vec<TestCasePayload>,
|
|
|
|
|
pub integration: Vec<TestCasePayload>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Object)]
|
|
|
|
|
struct AcceptanceRequest {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Object)]
|
|
|
|
|
struct TestRunSummaryResponse {
|
|
|
|
|
pub total: usize,
|
|
|
|
|
pub passed: usize,
|
|
|
|
|
pub failed: usize,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 14:45:57 +00:00
|
|
|
#[derive(Object)]
|
|
|
|
|
struct CoverageReportResponse {
|
|
|
|
|
pub current_percent: f64,
|
|
|
|
|
pub threshold_percent: f64,
|
|
|
|
|
pub baseline_percent: Option<f64>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
#[derive(Object)]
|
|
|
|
|
struct AcceptanceResponse {
|
|
|
|
|
pub can_accept: bool,
|
|
|
|
|
pub reasons: Vec<String>,
|
|
|
|
|
pub warning: Option<String>,
|
|
|
|
|
pub summary: TestRunSummaryResponse,
|
|
|
|
|
pub missing_categories: Vec<String>,
|
2026-02-19 14:45:57 +00:00
|
|
|
pub coverage_report: Option<CoverageReportResponse>,
|
2026-02-19 12:54:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Object)]
|
|
|
|
|
struct ReviewStory {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
pub can_accept: bool,
|
|
|
|
|
pub reasons: Vec<String>,
|
|
|
|
|
pub warning: Option<String>,
|
|
|
|
|
pub summary: TestRunSummaryResponse,
|
|
|
|
|
pub missing_categories: Vec<String>,
|
2026-02-19 14:45:57 +00:00
|
|
|
pub coverage_report: Option<CoverageReportResponse>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Object)]
|
|
|
|
|
struct RecordCoveragePayload {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
pub current_percent: f64,
|
|
|
|
|
pub threshold_percent: Option<f64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Object)]
|
|
|
|
|
struct CollectCoverageRequest {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
pub threshold_percent: Option<f64>,
|
2026-02-19 12:54:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Object)]
|
|
|
|
|
struct ReviewListResponse {
|
|
|
|
|
pub stories: Vec<ReviewStory>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
|
|
|
|
|
let root = ctx.state.get_project_root()?;
|
|
|
|
|
let current_dir = root.join(".story_kit").join("stories").join("current");
|
|
|
|
|
|
|
|
|
|
if !current_dir.exists() {
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut stories = Vec::new();
|
|
|
|
|
for entry in fs::read_dir(¤t_dir)
|
|
|
|
|
.map_err(|e| format!("Failed to read current stories directory: {e}"))?
|
|
|
|
|
{
|
|
|
|
|
let entry = entry.map_err(|e| format!("Failed to read current story entry: {e}"))?;
|
|
|
|
|
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())
|
|
|
|
|
.ok_or_else(|| "Invalid story file name.".to_string())?
|
|
|
|
|
.to_string();
|
|
|
|
|
let contents = fs::read_to_string(&path)
|
|
|
|
|
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
|
|
|
|
|
let metadata = parse_front_matter(&contents)
|
|
|
|
|
.map_err(|e| format!("Failed to parse front matter for {story_id}: {e:?}"))?;
|
|
|
|
|
stories.push((story_id, metadata));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(stories)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 14:45:57 +00:00
|
|
|
fn to_review_story(
|
|
|
|
|
story_id: &str,
|
|
|
|
|
results: &StoryTestResults,
|
|
|
|
|
coverage: Option<&CoverageReport>,
|
|
|
|
|
) -> ReviewStory {
|
|
|
|
|
let decision = evaluate_acceptance_with_coverage(results, coverage);
|
2026-02-19 12:54:04 +00:00
|
|
|
let summary = summarize_results(results);
|
|
|
|
|
|
|
|
|
|
let mut missing_categories = Vec::new();
|
|
|
|
|
let mut reasons = decision.reasons;
|
|
|
|
|
|
|
|
|
|
if results.unit.is_empty() {
|
|
|
|
|
missing_categories.push("unit".to_string());
|
|
|
|
|
reasons.push("Missing unit test results.".to_string());
|
|
|
|
|
}
|
|
|
|
|
if results.integration.is_empty() {
|
|
|
|
|
missing_categories.push("integration".to_string());
|
|
|
|
|
reasons.push("Missing integration test results.".to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let can_accept = decision.can_accept && missing_categories.is_empty();
|
|
|
|
|
|
2026-02-19 14:45:57 +00:00
|
|
|
let coverage_report = coverage.map(|c| CoverageReportResponse {
|
|
|
|
|
current_percent: c.current_percent,
|
|
|
|
|
threshold_percent: c.threshold_percent,
|
|
|
|
|
baseline_percent: c.baseline_percent,
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
ReviewStory {
|
|
|
|
|
story_id: story_id.to_string(),
|
|
|
|
|
can_accept,
|
|
|
|
|
reasons,
|
|
|
|
|
warning: decision.warning,
|
|
|
|
|
summary: TestRunSummaryResponse {
|
|
|
|
|
total: summary.total,
|
|
|
|
|
passed: summary.passed,
|
|
|
|
|
failed: summary.failed,
|
|
|
|
|
},
|
|
|
|
|
missing_categories,
|
2026-02-19 14:45:57 +00:00
|
|
|
coverage_report,
|
2026-02-19 12:54:04 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct WorkflowApi {
|
|
|
|
|
pub ctx: Arc<AppContext>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[OpenApi(tag = "WorkflowTags::Workflow")]
|
|
|
|
|
impl WorkflowApi {
|
|
|
|
|
/// Record test results for a story (unit + integration).
|
|
|
|
|
#[oai(path = "/workflow/tests/record", method = "post")]
|
|
|
|
|
async fn record_tests(&self, payload: Json<RecordTestsPayload>) -> OpenApiResult<Json<bool>> {
|
|
|
|
|
let unit = payload
|
|
|
|
|
.0
|
|
|
|
|
.unit
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(to_test_case)
|
|
|
|
|
.collect::<Result<Vec<_>, String>>()
|
|
|
|
|
.map_err(bad_request)?;
|
|
|
|
|
let integration = payload
|
|
|
|
|
.0
|
|
|
|
|
.integration
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(to_test_case)
|
|
|
|
|
.collect::<Result<Vec<_>, String>>()
|
|
|
|
|
.map_err(bad_request)?;
|
|
|
|
|
|
|
|
|
|
let mut workflow = self
|
|
|
|
|
.ctx
|
|
|
|
|
.workflow
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
|
|
|
workflow
|
|
|
|
|
.record_test_results_validated(payload.0.story_id, unit, integration)
|
|
|
|
|
.map_err(bad_request)?;
|
|
|
|
|
|
|
|
|
|
Ok(Json(true))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Evaluate acceptance readiness for a story.
|
|
|
|
|
#[oai(path = "/workflow/acceptance", method = "post")]
|
|
|
|
|
async fn acceptance(
|
|
|
|
|
&self,
|
|
|
|
|
payload: Json<AcceptanceRequest>,
|
|
|
|
|
) -> OpenApiResult<Json<AcceptanceResponse>> {
|
2026-02-19 14:45:57 +00:00
|
|
|
let (results, coverage) = {
|
2026-02-19 12:54:04 +00:00
|
|
|
let workflow = self
|
|
|
|
|
.ctx
|
|
|
|
|
.workflow
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
2026-02-19 14:45:57 +00:00
|
|
|
let results = workflow
|
2026-02-19 12:54:04 +00:00
|
|
|
.results
|
|
|
|
|
.get(&payload.0.story_id)
|
|
|
|
|
.cloned()
|
2026-02-19 14:45:57 +00:00
|
|
|
.unwrap_or_default();
|
|
|
|
|
let coverage = workflow.coverage.get(&payload.0.story_id).cloned();
|
|
|
|
|
(results, coverage)
|
2026-02-19 12:54:04 +00:00
|
|
|
};
|
|
|
|
|
|
2026-02-19 14:45:57 +00:00
|
|
|
let decision =
|
|
|
|
|
evaluate_acceptance_with_coverage(&results, coverage.as_ref());
|
2026-02-19 12:54:04 +00:00
|
|
|
let summary = summarize_results(&results);
|
|
|
|
|
|
|
|
|
|
let mut missing_categories = Vec::new();
|
|
|
|
|
let mut reasons = decision.reasons;
|
|
|
|
|
|
|
|
|
|
if results.unit.is_empty() {
|
|
|
|
|
missing_categories.push("unit".to_string());
|
|
|
|
|
reasons.push("Missing unit test results.".to_string());
|
|
|
|
|
}
|
|
|
|
|
if results.integration.is_empty() {
|
|
|
|
|
missing_categories.push("integration".to_string());
|
|
|
|
|
reasons.push("Missing integration test results.".to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let can_accept = decision.can_accept && missing_categories.is_empty();
|
|
|
|
|
|
2026-02-19 14:45:57 +00:00
|
|
|
let coverage_report = coverage.map(|c| CoverageReportResponse {
|
|
|
|
|
current_percent: c.current_percent,
|
|
|
|
|
threshold_percent: c.threshold_percent,
|
|
|
|
|
baseline_percent: c.baseline_percent,
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
Ok(Json(AcceptanceResponse {
|
|
|
|
|
can_accept,
|
|
|
|
|
reasons,
|
|
|
|
|
warning: decision.warning,
|
|
|
|
|
summary: TestRunSummaryResponse {
|
|
|
|
|
total: summary.total,
|
|
|
|
|
passed: summary.passed,
|
|
|
|
|
failed: summary.failed,
|
|
|
|
|
},
|
|
|
|
|
missing_categories,
|
2026-02-19 14:45:57 +00:00
|
|
|
coverage_report,
|
2026-02-19 12:54:04 +00:00
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// List stories that are ready for human review.
|
|
|
|
|
#[oai(path = "/workflow/review", method = "get")]
|
|
|
|
|
async fn review_queue(&self) -> OpenApiResult<Json<ReviewListResponse>> {
|
|
|
|
|
let stories = {
|
|
|
|
|
let workflow = self
|
|
|
|
|
.ctx
|
|
|
|
|
.workflow
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
|
|
|
workflow
|
|
|
|
|
.results
|
|
|
|
|
.iter()
|
2026-02-19 14:45:57 +00:00
|
|
|
.map(|(story_id, results)| {
|
|
|
|
|
let coverage = workflow.coverage.get(story_id);
|
|
|
|
|
to_review_story(story_id, results, coverage)
|
|
|
|
|
})
|
2026-02-19 12:54:04 +00:00
|
|
|
.filter(|story| story.can_accept)
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(Json(ReviewListResponse { stories }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// List stories in the review queue, including blocked items and current stories.
|
|
|
|
|
#[oai(path = "/workflow/review/all", method = "get")]
|
|
|
|
|
async fn review_queue_all(&self) -> OpenApiResult<Json<ReviewListResponse>> {
|
|
|
|
|
let current_stories =
|
|
|
|
|
load_current_story_metadata(self.ctx.as_ref()).map_err(bad_request)?;
|
|
|
|
|
let stories = {
|
|
|
|
|
let mut workflow = self
|
|
|
|
|
.ctx
|
|
|
|
|
.workflow
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
if !current_stories.is_empty() {
|
|
|
|
|
workflow.load_story_metadata(current_stories);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut story_ids = BTreeSet::new();
|
|
|
|
|
|
|
|
|
|
for story_id in workflow.results.keys() {
|
|
|
|
|
story_ids.insert(story_id.clone());
|
|
|
|
|
}
|
|
|
|
|
for story_id in workflow.stories.keys() {
|
|
|
|
|
story_ids.insert(story_id.clone());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
story_ids
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|story_id| {
|
|
|
|
|
let results = workflow.results.get(&story_id).cloned().unwrap_or_default();
|
2026-02-19 14:45:57 +00:00
|
|
|
let coverage = workflow.coverage.get(&story_id);
|
|
|
|
|
to_review_story(&story_id, &results, coverage)
|
2026-02-19 12:54:04 +00:00
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(Json(ReviewListResponse { stories }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 14:45:57 +00:00
|
|
|
/// Record coverage data for a story.
|
|
|
|
|
#[oai(path = "/workflow/coverage/record", method = "post")]
|
|
|
|
|
async fn record_coverage(
|
|
|
|
|
&self,
|
|
|
|
|
payload: Json<RecordCoveragePayload>,
|
|
|
|
|
) -> OpenApiResult<Json<bool>> {
|
|
|
|
|
let mut workflow = self
|
|
|
|
|
.ctx
|
|
|
|
|
.workflow
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
|
|
|
workflow.record_coverage(
|
|
|
|
|
payload.0.story_id,
|
|
|
|
|
payload.0.current_percent,
|
|
|
|
|
payload.0.threshold_percent,
|
|
|
|
|
);
|
|
|
|
|
Ok(Json(true))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Run coverage collection: execute test:coverage, parse output, record result.
|
|
|
|
|
#[oai(path = "/workflow/coverage/collect", method = "post")]
|
|
|
|
|
async fn collect_coverage(
|
|
|
|
|
&self,
|
|
|
|
|
payload: Json<CollectCoverageRequest>,
|
|
|
|
|
) -> OpenApiResult<Json<CoverageReportResponse>> {
|
|
|
|
|
let root = self
|
|
|
|
|
.ctx
|
|
|
|
|
.state
|
|
|
|
|
.get_project_root()
|
|
|
|
|
.map_err(bad_request)?;
|
|
|
|
|
|
|
|
|
|
let frontend_dir = root.join("frontend");
|
|
|
|
|
|
|
|
|
|
// Run pnpm run test:coverage in the frontend directory
|
|
|
|
|
let output = tokio::task::spawn_blocking(move || {
|
|
|
|
|
std::process::Command::new("pnpm")
|
|
|
|
|
.args(["run", "test:coverage"])
|
|
|
|
|
.current_dir(&frontend_dir)
|
|
|
|
|
.output()
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| bad_request(format!("Task join error: {e}")))?
|
|
|
|
|
.map_err(|e| bad_request(format!("Failed to run coverage command: {e}")))?;
|
|
|
|
|
|
|
|
|
|
if !output.status.success() {
|
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
|
return Err(bad_request(format!("Coverage command failed: {stderr}")));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Read the coverage summary JSON
|
|
|
|
|
let summary_path = root
|
|
|
|
|
.join("frontend")
|
|
|
|
|
.join("coverage")
|
|
|
|
|
.join("coverage-summary.json");
|
|
|
|
|
let json_str = fs::read_to_string(&summary_path)
|
|
|
|
|
.map_err(|e| bad_request(format!("Failed to read coverage summary: {e}")))?;
|
|
|
|
|
|
|
|
|
|
let current_percent = parse_coverage_json(&json_str).map_err(bad_request)?;
|
|
|
|
|
|
|
|
|
|
// Record coverage in workflow state
|
|
|
|
|
let coverage_report = {
|
|
|
|
|
let mut workflow = self
|
|
|
|
|
.ctx
|
|
|
|
|
.workflow
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
|
|
|
workflow.record_coverage(
|
|
|
|
|
payload.0.story_id.clone(),
|
|
|
|
|
current_percent,
|
|
|
|
|
payload.0.threshold_percent,
|
|
|
|
|
);
|
|
|
|
|
workflow
|
|
|
|
|
.coverage
|
|
|
|
|
.get(&payload.0.story_id)
|
|
|
|
|
.cloned()
|
|
|
|
|
.expect("just inserted")
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(Json(CoverageReportResponse {
|
|
|
|
|
current_percent: coverage_report.current_percent,
|
|
|
|
|
threshold_percent: coverage_report.threshold_percent,
|
|
|
|
|
baseline_percent: coverage_report.baseline_percent,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
/// Ensure a story can be accepted; returns an error when gates fail.
|
|
|
|
|
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
|
|
|
|
|
async fn ensure_acceptance(
|
|
|
|
|
&self,
|
|
|
|
|
payload: Json<AcceptanceRequest>,
|
|
|
|
|
) -> OpenApiResult<Json<bool>> {
|
|
|
|
|
let response = self.acceptance(payload).await?.0;
|
|
|
|
|
if response.can_accept {
|
|
|
|
|
return Ok(Json(true));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut parts = Vec::new();
|
|
|
|
|
if !response.reasons.is_empty() {
|
|
|
|
|
parts.push(response.reasons.join("; "));
|
|
|
|
|
}
|
|
|
|
|
if let Some(warning) = response.warning {
|
|
|
|
|
parts.push(warning);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let message = if parts.is_empty() {
|
|
|
|
|
"Acceptance is blocked.".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
format!("Acceptance is blocked: {}", parts.join("; "))
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Err(bad_request(message))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn to_test_case(input: TestCasePayload) -> Result<TestCaseResult, String> {
|
|
|
|
|
let status = parse_test_status(&input.status)?;
|
|
|
|
|
Ok(TestCaseResult {
|
|
|
|
|
name: input.name,
|
|
|
|
|
status,
|
|
|
|
|
details: input.details,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_test_status(value: &str) -> Result<TestStatus, String> {
|
|
|
|
|
match value {
|
|
|
|
|
"pass" => Ok(TestStatus::Pass),
|
|
|
|
|
"fail" => Ok(TestStatus::Fail),
|
|
|
|
|
other => Err(format!(
|
|
|
|
|
"Invalid test status '{other}'. Use 'pass' or 'fail'."
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_test_status_pass() {
|
|
|
|
|
assert_eq!(parse_test_status("pass").unwrap(), TestStatus::Pass);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_test_status_fail() {
|
|
|
|
|
assert_eq!(parse_test_status("fail").unwrap(), TestStatus::Fail);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_test_status_invalid() {
|
|
|
|
|
let result = parse_test_status("unknown");
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
assert!(result.unwrap_err().contains("Invalid test status"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_test_case_converts_pass() {
|
|
|
|
|
let payload = TestCasePayload {
|
|
|
|
|
name: "my_test".to_string(),
|
|
|
|
|
status: "pass".to_string(),
|
|
|
|
|
details: Some("all good".to_string()),
|
|
|
|
|
};
|
|
|
|
|
let result = to_test_case(payload).unwrap();
|
|
|
|
|
assert_eq!(result.name, "my_test");
|
|
|
|
|
assert_eq!(result.status, TestStatus::Pass);
|
|
|
|
|
assert_eq!(result.details, Some("all good".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_test_case_rejects_invalid_status() {
|
|
|
|
|
let payload = TestCasePayload {
|
|
|
|
|
name: "bad".to_string(),
|
|
|
|
|
status: "maybe".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
};
|
|
|
|
|
assert!(to_test_case(payload).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_review_story_all_passing() {
|
|
|
|
|
let results = StoryTestResults {
|
|
|
|
|
unit: vec![TestCaseResult {
|
|
|
|
|
name: "u1".to_string(),
|
|
|
|
|
status: TestStatus::Pass,
|
|
|
|
|
details: None,
|
|
|
|
|
}],
|
|
|
|
|
integration: vec![TestCaseResult {
|
|
|
|
|
name: "i1".to_string(),
|
|
|
|
|
status: TestStatus::Pass,
|
|
|
|
|
details: None,
|
|
|
|
|
}],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let review = to_review_story("story-29", &results);
|
|
|
|
|
assert!(review.can_accept);
|
|
|
|
|
assert!(review.reasons.is_empty());
|
|
|
|
|
assert!(review.missing_categories.is_empty());
|
|
|
|
|
assert_eq!(review.summary.total, 2);
|
|
|
|
|
assert_eq!(review.summary.passed, 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_review_story_missing_integration() {
|
|
|
|
|
let results = StoryTestResults {
|
|
|
|
|
unit: vec![TestCaseResult {
|
|
|
|
|
name: "u1".to_string(),
|
|
|
|
|
status: TestStatus::Pass,
|
|
|
|
|
details: None,
|
|
|
|
|
}],
|
|
|
|
|
integration: vec![],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let review = to_review_story("story-29", &results);
|
|
|
|
|
assert!(!review.can_accept);
|
|
|
|
|
assert!(review.missing_categories.contains(&"integration".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_review_story_with_failures() {
|
|
|
|
|
let results = StoryTestResults {
|
|
|
|
|
unit: vec![TestCaseResult {
|
|
|
|
|
name: "u1".to_string(),
|
|
|
|
|
status: TestStatus::Fail,
|
|
|
|
|
details: None,
|
|
|
|
|
}],
|
|
|
|
|
integration: vec![TestCaseResult {
|
|
|
|
|
name: "i1".to_string(),
|
|
|
|
|
status: TestStatus::Pass,
|
|
|
|
|
details: None,
|
|
|
|
|
}],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let review = to_review_story("story-29", &results);
|
|
|
|
|
assert!(!review.can_accept);
|
|
|
|
|
assert_eq!(review.summary.failed, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|