//! Workflow module: story gating and test result tracking. //! //! This module provides the in-memory primitives for: //! - reading story metadata (front matter) for gating decisions //! - tracking test run results //! - evaluating acceptance readiness //! //! NOTE: This is a naive, local-only implementation that will be //! refactored later into orchestration-aware components. use crate::io::story_metadata::{StoryMetadata, TestPlanStatus}; use std::collections::HashMap; #[derive(Debug, Clone, PartialEq, Eq)] pub enum TestStatus { Pass, Fail, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TestCaseResult { pub name: String, pub status: TestStatus, pub details: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TestRunSummary { pub total: usize, pub passed: usize, pub failed: usize, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AcceptanceDecision { pub can_accept: bool, pub reasons: Vec, pub warning: Option, } #[derive(Debug, Clone, Default)] pub struct StoryTestResults { pub unit: Vec, pub integration: Vec, } #[derive(Debug, Clone, Default)] #[allow(dead_code)] pub struct WorkflowState { pub stories: HashMap, pub results: HashMap, pub coverage: HashMap, } #[allow(dead_code)] impl WorkflowState { pub fn upsert_story(&mut self, story_id: String, metadata: StoryMetadata) { self.stories.insert(story_id, metadata); } pub fn load_story_metadata(&mut self, stories: Vec<(String, StoryMetadata)>) { for (story_id, metadata) in stories { self.stories.insert(story_id, metadata); } } pub fn refresh_story_metadata(&mut self, story_id: String, metadata: StoryMetadata) -> bool { match self.stories.get(&story_id) { Some(existing) if existing == &metadata => false, _ => { self.stories.insert(story_id, metadata); true } } } pub fn record_test_results( &mut self, story_id: String, unit: Vec, integration: Vec, ) { let _ = self.record_test_results_validated(story_id, unit, integration); } pub fn record_test_results_validated( &mut self, story_id: String, unit: Vec, integration: Vec, ) -> Result<(), String> { let failures = unit .iter() .chain(integration.iter()) .filter(|test| test.status == TestStatus::Fail) .count(); if failures > 1 { return Err(format!( "Multiple failing tests detected ({failures}); register failures one at a time." )); } self.results .insert(story_id, StoryTestResults { unit, integration }); Ok(()) } pub fn record_coverage( &mut self, story_id: String, current_percent: f64, threshold_percent: Option, ) { let threshold = threshold_percent.unwrap_or(80.0); let baseline = self .coverage .get(&story_id) .map(|existing| existing.baseline_percent.unwrap_or(existing.current_percent)); self.coverage.insert( story_id, CoverageReport { current_percent, threshold_percent: threshold, baseline_percent: baseline, }, ); } } #[allow(dead_code)] pub fn can_start_implementation(metadata: &StoryMetadata) -> Result<(), String> { match metadata.test_plan { Some(TestPlanStatus::Approved) => Ok(()), Some(TestPlanStatus::WaitingForApproval) => { Err("Test plan is waiting for approval; implementation is blocked.".to_string()) } Some(TestPlanStatus::Unknown(ref value)) => Err(format!( "Test plan state is unknown ({value}); implementation is blocked." )), None => Err("Missing test plan status; implementation is blocked.".to_string()), } } pub fn summarize_results(results: &StoryTestResults) -> TestRunSummary { let mut total = 0; let mut passed = 0; let mut failed = 0; for test in results.unit.iter().chain(results.integration.iter()) { total += 1; match test.status { TestStatus::Pass => passed += 1, TestStatus::Fail => failed += 1, } } TestRunSummary { total, passed, failed, } } pub fn evaluate_acceptance(results: &StoryTestResults) -> AcceptanceDecision { let summary = summarize_results(results); if summary.failed == 0 && summary.total > 0 { return AcceptanceDecision { can_accept: true, reasons: Vec::new(), warning: None, }; } let mut reasons = Vec::new(); if summary.total == 0 { reasons.push("No test results recorded for the story.".to_string()); } if summary.failed > 0 { reasons.push(format!( "{} test(s) are failing; acceptance is blocked.", summary.failed )); } let warning = if summary.failed > 1 { Some(format!( "Multiple tests are failing ({} failures).", summary.failed )) } else { None }; AcceptanceDecision { can_accept: false, reasons, warning, } } /// Coverage report for a story. #[derive(Debug, Clone, PartialEq)] pub struct CoverageReport { pub current_percent: f64, pub threshold_percent: f64, pub baseline_percent: Option, } /// Parse coverage percentage from a vitest coverage-summary.json string. /// Expects JSON with `{"total": {"lines": {"pct": }}}`. pub fn parse_coverage_json(json_str: &str) -> Result { let value: serde_json::Value = serde_json::from_str(json_str).map_err(|e| format!("Invalid coverage JSON: {e}"))?; value .get("total") .and_then(|t| t.get("lines")) .and_then(|l| l.get("pct")) .and_then(|p| p.as_f64()) .ok_or_else(|| "Missing total.lines.pct in coverage JSON.".to_string()) } /// Check whether coverage meets the threshold. #[allow(dead_code)] pub fn check_coverage_threshold(current: f64, threshold: f64) -> Result<(), String> { if current >= threshold { Ok(()) } else { Err(format!( "Coverage below threshold ({current:.1}% < {threshold:.1}%)." )) } } /// Evaluate acceptance with optional coverage data. pub fn evaluate_acceptance_with_coverage( results: &StoryTestResults, coverage: Option<&CoverageReport>, ) -> AcceptanceDecision { let mut decision = evaluate_acceptance(results); if let Some(report) = coverage { if report.current_percent < report.threshold_percent { decision.can_accept = false; decision.reasons.push(format!( "Coverage below threshold ({:.1}% < {:.1}%).", report.current_percent, report.threshold_percent )); } if let Some(baseline) = report.baseline_percent && report.current_percent < baseline { decision.can_accept = false; decision.reasons.push(format!( "Coverage regression: {:.1}% → {:.1}% (threshold: {:.1}%).", baseline, report.current_percent, report.threshold_percent )); } } decision } #[cfg(test)] mod tests { use super::*; // === parse_coverage_json === #[test] fn parses_valid_coverage_json() { let json = r#"{"total":{"lines":{"total":100,"covered":85,"pct":85.0},"statements":{"pct":85.0}}}"#; assert_eq!(parse_coverage_json(json).unwrap(), 85.0); } #[test] fn rejects_invalid_coverage_json() { assert!(parse_coverage_json("not json").is_err()); } #[test] fn rejects_missing_total_lines_pct() { let json = r#"{"total":{"branches":{"pct":90.0}}}"#; assert!(parse_coverage_json(json).is_err()); } // === AC1: check_coverage_threshold === #[test] fn coverage_threshold_passes_when_met() { assert!(check_coverage_threshold(80.0, 80.0).is_ok()); assert!(check_coverage_threshold(95.5, 80.0).is_ok()); } #[test] fn coverage_threshold_fails_when_below() { let result = check_coverage_threshold(72.3, 80.0); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("72.3%")); assert!(err.contains("80.0%")); } // === AC2: evaluate_acceptance_with_coverage === #[test] fn acceptance_blocked_by_coverage_below_threshold() { let results = StoryTestResults { unit: vec![TestCaseResult { name: "unit-1".to_string(), status: TestStatus::Pass, details: None, }], integration: vec![TestCaseResult { name: "int-1".to_string(), status: TestStatus::Pass, details: None, }], }; let coverage = CoverageReport { current_percent: 55.0, threshold_percent: 80.0, baseline_percent: None, }; let decision = evaluate_acceptance_with_coverage(&results, Some(&coverage)); assert!(!decision.can_accept); assert!(decision.reasons.iter().any(|r| r.contains("Coverage below threshold"))); } #[test] fn acceptance_blocked_by_coverage_regression() { let results = StoryTestResults { unit: vec![TestCaseResult { name: "unit-1".to_string(), status: TestStatus::Pass, details: None, }], integration: vec![TestCaseResult { name: "int-1".to_string(), status: TestStatus::Pass, details: None, }], }; let coverage = CoverageReport { current_percent: 82.0, threshold_percent: 80.0, baseline_percent: Some(90.0), }; let decision = evaluate_acceptance_with_coverage(&results, Some(&coverage)); assert!(!decision.can_accept); assert!(decision.reasons.iter().any(|r| r.contains("Coverage regression"))); } #[test] fn acceptance_passes_with_good_coverage() { let results = StoryTestResults { unit: vec![TestCaseResult { name: "unit-1".to_string(), status: TestStatus::Pass, details: None, }], integration: vec![TestCaseResult { name: "int-1".to_string(), status: TestStatus::Pass, details: None, }], }; let coverage = CoverageReport { current_percent: 92.0, threshold_percent: 80.0, baseline_percent: Some(90.0), }; let decision = evaluate_acceptance_with_coverage(&results, Some(&coverage)); assert!(decision.can_accept); } #[test] fn acceptance_works_without_coverage_data() { let results = StoryTestResults { unit: vec![TestCaseResult { name: "unit-1".to_string(), status: TestStatus::Pass, details: None, }], integration: vec![TestCaseResult { name: "int-1".to_string(), status: TestStatus::Pass, details: None, }], }; let decision = evaluate_acceptance_with_coverage(&results, None); assert!(decision.can_accept); } // === record_coverage === #[test] fn record_coverage_first_time_has_no_baseline() { let mut state = WorkflowState::default(); state.record_coverage("story-27".to_string(), 85.0, Some(80.0)); let report = state.coverage.get("story-27").unwrap(); assert_eq!(report.current_percent, 85.0); assert_eq!(report.threshold_percent, 80.0); assert_eq!(report.baseline_percent, None); } #[test] fn record_coverage_subsequent_sets_baseline() { let mut state = WorkflowState::default(); state.record_coverage("story-27".to_string(), 85.0, Some(80.0)); state.record_coverage("story-27".to_string(), 78.0, Some(80.0)); let report = state.coverage.get("story-27").unwrap(); assert_eq!(report.current_percent, 78.0); assert_eq!(report.baseline_percent, Some(85.0)); } #[test] fn record_coverage_default_threshold() { let mut state = WorkflowState::default(); state.record_coverage("story-27".to_string(), 90.0, None); let report = state.coverage.get("story-27").unwrap(); assert_eq!(report.threshold_percent, 80.0); } #[test] fn record_coverage_custom_threshold() { let mut state = WorkflowState::default(); state.record_coverage("story-27".to_string(), 90.0, Some(95.0)); let report = state.coverage.get("story-27").unwrap(); assert_eq!(report.threshold_percent, 95.0); } // === Existing tests === #[test] fn warns_when_multiple_tests_fail() { let results = StoryTestResults { unit: vec![ TestCaseResult { name: "unit-1".to_string(), status: TestStatus::Fail, details: None, }, TestCaseResult { name: "unit-2".to_string(), status: TestStatus::Fail, details: None, }, ], integration: vec![TestCaseResult { name: "integration-1".to_string(), status: TestStatus::Pass, details: None, }], }; let decision = evaluate_acceptance(&results); assert!(!decision.can_accept); assert_eq!( decision.warning, Some("Multiple tests are failing (2 failures).".to_string()) ); } #[test] fn rejects_recording_multiple_failures() { let mut state = WorkflowState::default(); let unit = vec![ TestCaseResult { name: "unit-1".to_string(), status: TestStatus::Fail, details: None, }, TestCaseResult { name: "unit-2".to_string(), status: TestStatus::Fail, details: None, }, ]; let integration = vec![TestCaseResult { name: "integration-1".to_string(), status: TestStatus::Pass, details: None, }]; let result = state.record_test_results_validated("story-26".to_string(), unit, integration); assert!(result.is_err()); } #[test] fn accepts_when_all_tests_pass() { let results = StoryTestResults { unit: vec![TestCaseResult { name: "unit-1".to_string(), status: TestStatus::Pass, details: None, }], integration: vec![TestCaseResult { name: "integration-1".to_string(), status: TestStatus::Pass, details: None, }], }; let decision = evaluate_acceptance(&results); assert!(decision.can_accept); assert!(decision.reasons.is_empty()); assert!(decision.warning.is_none()); } #[test] fn rejects_when_no_results_recorded() { let results = StoryTestResults::default(); let decision = evaluate_acceptance(&results); assert!(!decision.can_accept); assert!(decision.reasons.iter().any(|r| r.contains("No test results"))); } #[test] fn rejects_with_single_failure_no_warning() { let results = StoryTestResults { unit: vec![ TestCaseResult { name: "unit-1".to_string(), status: TestStatus::Pass, details: None, }, TestCaseResult { name: "unit-2".to_string(), status: TestStatus::Fail, details: None, }, ], integration: vec![], }; let decision = evaluate_acceptance(&results); assert!(!decision.can_accept); assert!(decision.reasons.iter().any(|r| r.contains("failing"))); assert!(decision.warning.is_none()); } #[test] fn summarize_results_counts_correctly() { let results = StoryTestResults { unit: vec![ TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None }, TestCaseResult { name: "u2".to_string(), status: TestStatus::Fail, details: None }, ], integration: vec![ TestCaseResult { name: "i1".to_string(), status: TestStatus::Pass, details: None }, ], }; let summary = summarize_results(&results); assert_eq!(summary.total, 3); assert_eq!(summary.passed, 2); assert_eq!(summary.failed, 1); } #[test] fn can_start_implementation_requires_approved_plan() { let approved = StoryMetadata { name: Some("Test".to_string()), test_plan: Some(TestPlanStatus::Approved), }; assert!(can_start_implementation(&approved).is_ok()); let waiting = StoryMetadata { name: Some("Test".to_string()), test_plan: Some(TestPlanStatus::WaitingForApproval), }; assert!(can_start_implementation(&waiting).is_err()); let unknown = StoryMetadata { name: Some("Test".to_string()), test_plan: Some(TestPlanStatus::Unknown("draft".to_string())), }; assert!(can_start_implementation(&unknown).is_err()); let missing = StoryMetadata { name: Some("Test".to_string()), test_plan: None, }; assert!(can_start_implementation(&missing).is_err()); } #[test] fn record_valid_results_stores_them() { let mut state = WorkflowState::default(); let unit = vec![TestCaseResult { name: "unit-1".to_string(), status: TestStatus::Pass, details: None, }]; let integration = vec![TestCaseResult { name: "int-1".to_string(), status: TestStatus::Pass, details: None, }]; let result = state.record_test_results_validated( "story-29".to_string(), unit, integration, ); assert!(result.is_ok()); assert!(state.results.contains_key("story-29")); assert_eq!(state.results["story-29"].unit.len(), 1); assert_eq!(state.results["story-29"].integration.len(), 1); } #[test] fn refresh_story_metadata_returns_false_when_unchanged() { let mut state = WorkflowState::default(); let meta = StoryMetadata { name: Some("Test".to_string()), test_plan: Some(TestPlanStatus::Approved), }; assert!(state.refresh_story_metadata("s1".to_string(), meta.clone())); assert!(!state.refresh_story_metadata("s1".to_string(), meta.clone())); let updated = StoryMetadata { name: Some("Updated".to_string()), test_plan: Some(TestPlanStatus::Approved), }; assert!(state.refresh_story_metadata("s1".to_string(), updated)); } }