243 lines
6.5 KiB
Rust
243 lines
6.5 KiB
Rust
|
|
//! 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<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[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<String>,
|
||
|
|
pub warning: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Default)]
|
||
|
|
pub struct StoryTestResults {
|
||
|
|
pub unit: Vec<TestCaseResult>,
|
||
|
|
pub integration: Vec<TestCaseResult>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Default)]
|
||
|
|
#[allow(dead_code)]
|
||
|
|
pub struct WorkflowState {
|
||
|
|
pub stories: HashMap<String, StoryMetadata>,
|
||
|
|
pub results: HashMap<String, StoryTestResults>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[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<TestCaseResult>,
|
||
|
|
integration: Vec<TestCaseResult>,
|
||
|
|
) {
|
||
|
|
let _ = self.record_test_results_validated(story_id, unit, integration);
|
||
|
|
}
|
||
|
|
|
||
|
|
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(())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[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,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[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());
|
||
|
|
}
|
||
|
|
}
|