WIP: Batch 1 — backfill tests for store, search, and workflow
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -63,3 +63,101 @@ pub async fn search_files_impl(query: String, root: PathBuf) -> Result<Vec<Searc
|
|||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn setup_project(files: &[(&str, &str)]) -> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,3 +80,104 @@ impl StoreOps for JsonFileStore {
|
|||||||
fs::write(&self.path, content).map_err(|e| format!("Failed to write store: {e}"))
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -239,4 +239,145 @@ mod tests {
|
|||||||
|
|
||||||
assert!(result.is_err());
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user