Restore codebase deleted by bad auto-commit e4227cf
Commit e4227cf (a story creation auto-commit) erroneously deleted 175
files from master's tree, likely due to a race condition between
concurrent git operations. This commit re-adds all files from the
working directory.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
399
server/src/workflow.rs
Normal file
399
server/src/workflow.rs
Normal file
@@ -0,0 +1,399 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user