Files
storkit/server/src/workflow.rs

400 lines
11 KiB
Rust

//! Workflow module: test result tracking and acceptance evaluation.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TestStatus {
Pass,
Fail,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TestCaseResult {
pub name: String,
pub status: TestStatus,
pub details: Option<String>,
}
struct TestRunSummary {
total: usize,
failed: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AcceptanceDecision {
pub can_accept: bool,
pub reasons: Vec<String>,
pub warning: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StoryTestResults {
pub unit: Vec<TestCaseResult>,
pub integration: Vec<TestCaseResult>,
}
#[derive(Debug, Clone, Default)]
pub struct WorkflowState {
pub results: HashMap<String, StoryTestResults>,
pub coverage: HashMap<String, CoverageReport>,
}
impl WorkflowState {
pub fn record_test_results_validated(
&mut self,
story_id: String,
unit: Vec<TestCaseResult>,
integration: Vec<TestCaseResult>,
) -> 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<f64>,
}
/// 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);
}
}