//! Workflow module: test result tracking and acceptance evaluation. 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, } struct TestRunSummary { total: usize, 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)] pub struct WorkflowState { pub results: HashMap, pub coverage: HashMap, } impl WorkflowState { 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(()) } } fn summarize_results(results: &StoryTestResults) -> TestRunSummary { let mut total = 0; let mut failed = 0; for test in results.unit.iter().chain(results.integration.iter()) { total += 1; if test.status == TestStatus::Fail { failed += 1; } } TestRunSummary { total, failed } } 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, } /// 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::*; // === 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); } // === evaluate_acceptance === #[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 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()); } // === record_test_results_validated === #[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 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); } }