Story 26: Establish TDD workflow and quality gates
Add workflow engine with acceptance gates, test recording, and review queue. Frontend displays gate status (blocked/ready), test summaries, failing badges, and warnings. Proceed action is disabled when gates are not met. Includes 13 unit tests (Vitest) and 9 E2E tests (Playwright) covering all five acceptance criteria. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
242
server/src/workflow.rs
Normal file
242
server/src/workflow.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user