Story 27: Coverage tracking (full-stack)

Add end-to-end coverage tracking: backend collects vitest coverage,
records metrics with threshold/baseline tracking, and blocks acceptance
on regression. Frontend displays coverage in gate/review panels with
a "Collect Coverage" button. Includes 20 Rust tests, 17 Vitest tests,
and 14 Playwright E2E tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-19 14:45:57 +00:00
parent 8f0bc971bf
commit 8f684a6ca4
20 changed files with 1216 additions and 34 deletions

View File

@@ -49,6 +49,7 @@ pub struct StoryTestResults {
pub struct WorkflowState {
pub stories: HashMap<String, StoryMetadata>,
pub results: HashMap<String, StoryTestResults>,
pub coverage: HashMap<String, CoverageReport>,
}
#[allow(dead_code)]
@@ -105,6 +106,29 @@ impl WorkflowState {
Ok(())
}
pub fn record_coverage(
&mut self,
story_id: String,
current_percent: f64,
threshold_percent: Option<f64>,
) {
let threshold = threshold_percent.unwrap_or(80.0);
let baseline = self
.coverage
.get(&story_id)
.map(|existing| existing.baseline_percent.unwrap_or(existing.current_percent));
self.coverage.insert(
story_id,
CoverageReport {
current_percent,
threshold_percent: threshold,
baseline_percent: baseline,
},
);
}
}
#[allow(dead_code)]
@@ -179,10 +203,250 @@ pub fn evaluate_acceptance(results: &StoryTestResults) -> AcceptanceDecision {
}
}
/// 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>,
}
/// Parse coverage percentage from a vitest coverage-summary.json string.
/// Expects JSON with `{"total": {"lines": {"pct": <number>}}}`.
pub fn parse_coverage_json(json_str: &str) -> Result<f64, String> {
let value: serde_json::Value =
serde_json::from_str(json_str).map_err(|e| format!("Invalid coverage JSON: {e}"))?;
value
.get("total")
.and_then(|t| t.get("lines"))
.and_then(|l| l.get("pct"))
.and_then(|p| p.as_f64())
.ok_or_else(|| "Missing total.lines.pct in coverage JSON.".to_string())
}
/// Check whether coverage meets the threshold.
#[allow(dead_code)]
pub fn check_coverage_threshold(current: f64, threshold: f64) -> Result<(), String> {
if current >= threshold {
Ok(())
} else {
Err(format!(
"Coverage below threshold ({current:.1}% < {threshold:.1}%)."
))
}
}
/// 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::*;
// === parse_coverage_json ===
#[test]
fn parses_valid_coverage_json() {
let json = r#"{"total":{"lines":{"total":100,"covered":85,"pct":85.0},"statements":{"pct":85.0}}}"#;
assert_eq!(parse_coverage_json(json).unwrap(), 85.0);
}
#[test]
fn rejects_invalid_coverage_json() {
assert!(parse_coverage_json("not json").is_err());
}
#[test]
fn rejects_missing_total_lines_pct() {
let json = r#"{"total":{"branches":{"pct":90.0}}}"#;
assert!(parse_coverage_json(json).is_err());
}
// === AC1: check_coverage_threshold ===
#[test]
fn coverage_threshold_passes_when_met() {
assert!(check_coverage_threshold(80.0, 80.0).is_ok());
assert!(check_coverage_threshold(95.5, 80.0).is_ok());
}
#[test]
fn coverage_threshold_fails_when_below() {
let result = check_coverage_threshold(72.3, 80.0);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("72.3%"));
assert!(err.contains("80.0%"));
}
// === AC2: 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);
}
// === record_coverage ===
#[test]
fn record_coverage_first_time_has_no_baseline() {
let mut state = WorkflowState::default();
state.record_coverage("story-27".to_string(), 85.0, Some(80.0));
let report = state.coverage.get("story-27").unwrap();
assert_eq!(report.current_percent, 85.0);
assert_eq!(report.threshold_percent, 80.0);
assert_eq!(report.baseline_percent, None);
}
#[test]
fn record_coverage_subsequent_sets_baseline() {
let mut state = WorkflowState::default();
state.record_coverage("story-27".to_string(), 85.0, Some(80.0));
state.record_coverage("story-27".to_string(), 78.0, Some(80.0));
let report = state.coverage.get("story-27").unwrap();
assert_eq!(report.current_percent, 78.0);
assert_eq!(report.baseline_percent, Some(85.0));
}
#[test]
fn record_coverage_default_threshold() {
let mut state = WorkflowState::default();
state.record_coverage("story-27".to_string(), 90.0, None);
let report = state.coverage.get("story-27").unwrap();
assert_eq!(report.threshold_percent, 80.0);
}
#[test]
fn record_coverage_custom_threshold() {
let mut state = WorkflowState::default();
state.record_coverage("story-27".to_string(), 90.0, Some(95.0));
let report = state.coverage.get("story-27").unwrap();
assert_eq!(report.threshold_percent, 95.0);
}
// === Existing tests ===
#[test]
fn warns_when_multiple_tests_fail() {
let results = StoryTestResults {