use crate::io::story_metadata::write_coverage_baseline; use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; use std::fs; use std::path::Path; use super::{find_story_file, replace_or_append_section}; const TEST_RESULTS_MARKER: &str = "\n\n")); // Unit tests let (unit_pass, unit_fail) = count_pass_fail(&results.unit); s.push_str(&format!( "### Unit Tests ({unit_pass} passed, {unit_fail} failed)\n\n" )); if results.unit.is_empty() { s.push_str("*No unit tests recorded.*\n"); } else { for t in &results.unit { s.push_str(&format_test_line(t)); } } s.push('\n'); // Integration tests let (int_pass, int_fail) = count_pass_fail(&results.integration); s.push_str(&format!( "### Integration Tests ({int_pass} passed, {int_fail} failed)\n\n" )); if results.integration.is_empty() { s.push_str("*No integration tests recorded.*\n"); } else { for t in &results.integration { s.push_str(&format_test_line(t)); } } s } fn count_pass_fail(tests: &[TestCaseResult]) -> (usize, usize) { let pass = tests.iter().filter(|t| t.status == TestStatus::Pass).count(); (pass, tests.len() - pass) } fn format_test_line(t: &TestCaseResult) -> String { let icon = if t.status == TestStatus::Pass { "✅" } else { "❌" }; match &t.details { Some(d) if !d.is_empty() => format!("- {icon} {} — {d}\n", t.name), _ => format!("- {icon} {}\n", t.name), } } /// Parse `StoryTestResults` from the JSON embedded in the `## Test Results` section. fn parse_test_results_from_contents(contents: &str) -> Option { for line in contents.lines() { let trimmed = line.trim(); if let Some(rest) = trimmed.strip_prefix(TEST_RESULTS_MARKER) { // rest looks like: ` {...} -->` if let Some(json_end) = rest.rfind("-->") { let json_str = rest[..json_end].trim(); if let Ok(results) = serde_json::from_str::(json_str) { return Some(results); } } } } None } #[cfg(test)] mod tests { use super::*; use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; fn make_results() -> StoryTestResults { StoryTestResults { unit: vec![ TestCaseResult { name: "unit-pass".to_string(), status: TestStatus::Pass, details: None }, TestCaseResult { name: "unit-fail".to_string(), status: TestStatus::Fail, details: Some("assertion failed".to_string()) }, ], integration: vec![ TestCaseResult { name: "int-pass".to_string(), status: TestStatus::Pass, details: None }, ], } } #[test] fn write_and_read_test_results_roundtrip() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".story_kit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("1_story_test.md"), "---\nname: Test\n---\n# Story\n").unwrap(); let results = make_results(); write_test_results_to_story_file(tmp.path(), "1_story_test", &results).unwrap(); let read_back = read_test_results_from_story_file(tmp.path(), "1_story_test") .expect("should read back results"); assert_eq!(read_back.unit.len(), 2); assert_eq!(read_back.integration.len(), 1); assert_eq!(read_back.unit[0].name, "unit-pass"); assert_eq!(read_back.unit[1].status, TestStatus::Fail); assert_eq!(read_back.unit[1].details.as_deref(), Some("assertion failed")); } #[test] fn write_test_results_creates_readable_section() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".story_kit/work/2_current"); fs::create_dir_all(¤t).unwrap(); let story_path = current.join("2_story_check.md"); fs::write(&story_path, "---\nname: Check\n---\n# Story\n\n## Acceptance Criteria\n\n- [ ] AC1\n").unwrap(); let results = make_results(); write_test_results_to_story_file(tmp.path(), "2_story_check", &results).unwrap(); let contents = fs::read_to_string(&story_path).unwrap(); assert!(contents.contains("## Test Results")); assert!(contents.contains("✅ unit-pass")); assert!(contents.contains("❌ unit-fail")); assert!(contents.contains("assertion failed")); assert!(contents.contains("story-kit-test-results:")); // Original content still present assert!(contents.contains("## Acceptance Criteria")); } #[test] fn write_test_results_overwrites_existing_section() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".story_kit/work/2_current"); fs::create_dir_all(¤t).unwrap(); let story_path = current.join("3_story_overwrite.md"); fs::write( &story_path, "---\nname: Overwrite\n---\n# Story\n\n## Test Results\n\n\n\n### Unit Tests (0 passed, 0 failed)\n\n*No unit tests recorded.*\n", ) .unwrap(); let results = make_results(); write_test_results_to_story_file(tmp.path(), "3_story_overwrite", &results).unwrap(); let contents = fs::read_to_string(&story_path).unwrap(); assert!(contents.contains("✅ unit-pass")); // Should have only one ## Test Results header let count = contents.matches("## Test Results").count(); assert_eq!(count, 1, "should have exactly one ## Test Results section"); } #[test] fn read_test_results_returns_none_when_no_section() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".story_kit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("4_story_empty.md"), "---\nname: Empty\n---\n# Story\n").unwrap(); let result = read_test_results_from_story_file(tmp.path(), "4_story_empty"); assert!(result.is_none()); } #[test] fn read_test_results_returns_none_for_unknown_story() { let tmp = tempfile::tempdir().unwrap(); let result = read_test_results_from_story_file(tmp.path(), "99_story_unknown"); assert!(result.is_none()); } #[test] fn write_test_results_finds_story_in_any_stage() { let tmp = tempfile::tempdir().unwrap(); let qa_dir = tmp.path().join(".story_kit/work/3_qa"); fs::create_dir_all(&qa_dir).unwrap(); fs::write(qa_dir.join("5_story_qa.md"), "---\nname: QA Story\n---\n# Story\n").unwrap(); let results = StoryTestResults { unit: vec![TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None }], integration: vec![], }; write_test_results_to_story_file(tmp.path(), "5_story_qa", &results).unwrap(); let read_back = read_test_results_from_story_file(tmp.path(), "5_story_qa").unwrap(); assert_eq!(read_back.unit.len(), 1); } #[test] fn write_coverage_baseline_to_story_file_updates_front_matter() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".story_kit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("6_story_cov.md"), "---\nname: Cov Story\n---\n# Story\n").unwrap(); write_coverage_baseline_to_story_file(tmp.path(), "6_story_cov", 75.4).unwrap(); let contents = fs::read_to_string(current.join("6_story_cov.md")).unwrap(); assert!(contents.contains("coverage_baseline: 75.4%"), "got: {contents}"); } #[test] fn write_coverage_baseline_to_story_file_silent_on_missing_story() { let tmp = tempfile::tempdir().unwrap(); // Story doesn't exist — should succeed silently let result = write_coverage_baseline_to_story_file(tmp.path(), "99_story_missing", 50.0); assert!(result.is_ok()); } }