From 76e7c68b669cba17f3ffb718d09de7588f800a90 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Feb 2026 13:49:33 +0000 Subject: [PATCH] =?UTF-8?q?WIP:=20Batch=201=20=E2=80=94=20backfill=20tests?= =?UTF-8?q?=20for=20store,=20search,=20and=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store.rs: 8 tests (roundtrip, persistence, corrupt/empty file handling) - io/search.rs: 5 tests (matching, nested dirs, gitignore, empty results) - workflow.rs: 7 new tests (acceptance logic, summarize, can_start, record, refresh) Co-Authored-By: Claude Opus 4.6 --- server/src/io/search.rs | 98 ++++++++++++++++++++++++++++ server/src/store.rs | 101 ++++++++++++++++++++++++++++ server/src/workflow.rs | 141 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 340 insertions(+) diff --git a/server/src/io/search.rs b/server/src/io/search.rs index 5c70b67..da1168e 100644 --- a/server/src/io/search.rs +++ b/server/src/io/search.rs @@ -63,3 +63,101 @@ pub async fn search_files_impl(query: String, root: PathBuf) -> Result TempDir { + let dir = TempDir::new().unwrap(); + for (path, content) in files { + let full = dir.path().join(path); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(full, content).unwrap(); + } + dir + } + + #[tokio::test] + async fn finds_files_matching_query() { + let dir = setup_project(&[ + ("hello.txt", "hello world"), + ("goodbye.txt", "goodbye world"), + ]); + + let results = search_files_impl("hello".to_string(), dir.path().to_path_buf()) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "hello.txt"); + } + + #[tokio::test] + async fn returns_empty_for_no_matches() { + let dir = setup_project(&[("file.txt", "some content")]); + + let results = search_files_impl("nonexistent".to_string(), dir.path().to_path_buf()) + .await + .unwrap(); + + assert!(results.is_empty()); + } + + #[tokio::test] + async fn searches_nested_directories() { + let dir = setup_project(&[ + ("top.txt", "needle"), + ("sub/deep.txt", "needle in haystack"), + ("sub/other.txt", "no match here"), + ]); + + let results = search_files_impl("needle".to_string(), dir.path().to_path_buf()) + .await + .unwrap(); + + assert_eq!(results.len(), 2); + let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect(); + assert!(paths.contains(&"top.txt")); + assert!(paths.contains(&"sub/deep.txt")); + } + + #[tokio::test] + async fn skips_directories_only_matches_files() { + let dir = setup_project(&[("sub/file.txt", "content")]); + + let results = search_files_impl("content".to_string(), dir.path().to_path_buf()) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "sub/file.txt"); + } + + #[tokio::test] + async fn respects_gitignore() { + let dir = setup_project(&[ + (".gitignore", "ignored/\n"), + ("kept.txt", "search term"), + ("ignored/hidden.txt", "search term"), + ]); + + // Initialize a git repo so .gitignore is respected + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let results = search_files_impl("search term".to_string(), dir.path().to_path_buf()) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "kept.txt"); + } +} diff --git a/server/src/store.rs b/server/src/store.rs index 1bdd8d3..d70641c 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -80,3 +80,104 @@ impl StoreOps for JsonFileStore { fs::write(&self.path, content).map_err(|e| format!("Failed to write store: {e}")) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::TempDir; + + fn store_in(dir: &TempDir, name: &str) -> JsonFileStore { + let path = dir.path().join(name); + JsonFileStore::new(path).expect("store creation should succeed") + } + + #[test] + fn new_from_missing_file_creates_empty_store() { + let dir = TempDir::new().unwrap(); + let store = store_in(&dir, "missing.json"); + assert!(store.get("anything").is_none()); + } + + #[test] + fn new_from_empty_file_creates_empty_store() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("empty.json"); + fs::write(&path, "").unwrap(); + let store = JsonFileStore::new(path).expect("should handle empty file"); + assert!(store.get("anything").is_none()); + } + + #[test] + fn new_from_corrupt_file_returns_error() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("corrupt.json"); + fs::write(&path, "not valid json {{{").unwrap(); + let result = JsonFileStore::new(path); + match result { + Err(e) => assert!(e.contains("Failed to parse store"), "unexpected error: {e}"), + Ok(_) => panic!("expected error for corrupt file"), + } + } + + #[test] + fn get_set_delete_roundtrip() { + let dir = TempDir::new().unwrap(); + let store = store_in(&dir, "data.json"); + + assert!(store.get("key").is_none()); + + store.set("key", json!("value")); + assert_eq!(store.get("key"), Some(json!("value"))); + + store.set("key", json!(42)); + assert_eq!(store.get("key"), Some(json!(42))); + + store.delete("key"); + assert!(store.get("key").is_none()); + } + + #[test] + fn save_persists_and_reload_restores() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("persist.json"); + + { + let store = JsonFileStore::new(path.clone()).unwrap(); + store.set("name", json!("story-kit")); + store.set("version", json!(1)); + store.save().expect("save should succeed"); + } + + let store = JsonFileStore::new(path).unwrap(); + assert_eq!(store.get("name"), Some(json!("story-kit"))); + assert_eq!(store.get("version"), Some(json!(1))); + } + + #[test] + fn save_creates_parent_directories() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("nested").join("deep").join("store.json"); + let store = JsonFileStore::new(path.clone()).unwrap(); + store.set("key", json!("value")); + store.save().expect("save should create parent dirs"); + assert!(path.exists()); + } + + #[test] + fn delete_nonexistent_key_is_noop() { + let dir = TempDir::new().unwrap(); + let store = store_in(&dir, "data.json"); + store.delete("nonexistent"); + assert!(store.get("nonexistent").is_none()); + } + + #[test] + fn from_path_works_like_new() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("via_from.json"); + let store = JsonFileStore::from_path(&path).unwrap(); + store.set("test", json!(true)); + assert_eq!(store.get("test"), Some(json!(true))); + } +} diff --git a/server/src/workflow.rs b/server/src/workflow.rs index aad5a55..b98c88c 100644 --- a/server/src/workflow.rs +++ b/server/src/workflow.rs @@ -239,4 +239,145 @@ mod tests { assert!(result.is_err()); } + + #[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()); + } + + #[test] + fn summarize_results_counts_correctly() { + let results = StoryTestResults { + unit: vec![ + TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None }, + TestCaseResult { name: "u2".to_string(), status: TestStatus::Fail, details: None }, + ], + integration: vec![ + TestCaseResult { name: "i1".to_string(), status: TestStatus::Pass, details: None }, + ], + }; + + let summary = summarize_results(&results); + assert_eq!(summary.total, 3); + assert_eq!(summary.passed, 2); + assert_eq!(summary.failed, 1); + } + + #[test] + fn can_start_implementation_requires_approved_plan() { + let approved = StoryMetadata { + name: Some("Test".to_string()), + test_plan: Some(TestPlanStatus::Approved), + }; + assert!(can_start_implementation(&approved).is_ok()); + + let waiting = StoryMetadata { + name: Some("Test".to_string()), + test_plan: Some(TestPlanStatus::WaitingForApproval), + }; + assert!(can_start_implementation(&waiting).is_err()); + + let unknown = StoryMetadata { + name: Some("Test".to_string()), + test_plan: Some(TestPlanStatus::Unknown("draft".to_string())), + }; + assert!(can_start_implementation(&unknown).is_err()); + + let missing = StoryMetadata { + name: Some("Test".to_string()), + test_plan: None, + }; + assert!(can_start_implementation(&missing).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); + } + + #[test] + fn refresh_story_metadata_returns_false_when_unchanged() { + let mut state = WorkflowState::default(); + let meta = StoryMetadata { + name: Some("Test".to_string()), + test_plan: Some(TestPlanStatus::Approved), + }; + + assert!(state.refresh_story_metadata("s1".to_string(), meta.clone())); + assert!(!state.refresh_story_metadata("s1".to_string(), meta.clone())); + + let updated = StoryMetadata { + name: Some("Updated".to_string()), + test_plan: Some(TestPlanStatus::Approved), + }; + assert!(state.refresh_story_metadata("s1".to_string(), updated)); + } }