From 76e7c68b669cba17f3ffb718d09de7588f800a90 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Feb 2026 13:49:33 +0000 Subject: [PATCH 1/4] =?UTF-8?q?WIP:=20Batch=201=20=E2=80=94=20backfill=20t?= =?UTF-8?q?ests=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)); + } } From de6334720a9a8a9009f1e7ba06e7fbe6f7112464 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Feb 2026 13:52:19 +0000 Subject: [PATCH 2/4] =?UTF-8?q?WIP:=20Batch=202=20=E2=80=94=20backfill=20t?= =?UTF-8?q?ests=20for=20fs,=20shell,=20and=20http/workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - io/fs.rs: 20 tests (path resolution, project open/close/get, known projects, model prefs, file read/write, list dir, validate path, scaffold) - io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes) - http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story) Coverage: 28.6% → 48.1% Co-Authored-By: Claude Opus 4.6 --- server/src/http/workflow.rs | 105 ++++++++++++++ server/src/io/fs.rs | 270 ++++++++++++++++++++++++++++++++++++ server/src/io/shell.rs | 60 ++++++++ 3 files changed, 435 insertions(+) diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index b3b9e92..e1fdf76 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -317,3 +317,108 @@ fn parse_test_status(value: &str) -> Result { )), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; + + #[test] + fn parse_test_status_pass() { + assert_eq!(parse_test_status("pass").unwrap(), TestStatus::Pass); + } + + #[test] + fn parse_test_status_fail() { + assert_eq!(parse_test_status("fail").unwrap(), TestStatus::Fail); + } + + #[test] + fn parse_test_status_invalid() { + let result = parse_test_status("unknown"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid test status")); + } + + #[test] + fn to_test_case_converts_pass() { + let payload = TestCasePayload { + name: "my_test".to_string(), + status: "pass".to_string(), + details: Some("all good".to_string()), + }; + let result = to_test_case(payload).unwrap(); + assert_eq!(result.name, "my_test"); + assert_eq!(result.status, TestStatus::Pass); + assert_eq!(result.details, Some("all good".to_string())); + } + + #[test] + fn to_test_case_rejects_invalid_status() { + let payload = TestCasePayload { + name: "bad".to_string(), + status: "maybe".to_string(), + details: None, + }; + assert!(to_test_case(payload).is_err()); + } + + #[test] + fn to_review_story_all_passing() { + let results = StoryTestResults { + unit: vec![TestCaseResult { + name: "u1".to_string(), + status: TestStatus::Pass, + details: None, + }], + integration: vec![TestCaseResult { + name: "i1".to_string(), + status: TestStatus::Pass, + details: None, + }], + }; + + let review = to_review_story("story-29", &results); + assert!(review.can_accept); + assert!(review.reasons.is_empty()); + assert!(review.missing_categories.is_empty()); + assert_eq!(review.summary.total, 2); + assert_eq!(review.summary.passed, 2); + } + + #[test] + fn to_review_story_missing_integration() { + let results = StoryTestResults { + unit: vec![TestCaseResult { + name: "u1".to_string(), + status: TestStatus::Pass, + details: None, + }], + integration: vec![], + }; + + let review = to_review_story("story-29", &results); + assert!(!review.can_accept); + assert!(review.missing_categories.contains(&"integration".to_string())); + } + + #[test] + fn to_review_story_with_failures() { + let results = StoryTestResults { + unit: vec![TestCaseResult { + name: "u1".to_string(), + status: TestStatus::Fail, + details: None, + }], + integration: vec![TestCaseResult { + name: "i1".to_string(), + status: TestStatus::Pass, + details: None, + }], + }; + + let review = to_review_story("story-29", &results); + assert!(!review.can_accept); + assert_eq!(review.summary.failed, 1); + } +} diff --git a/server/src/io/fs.rs b/server/src/io/fs.rs index a776df9..ca630e9 100644 --- a/server/src/io/fs.rs +++ b/server/src/io/fs.rs @@ -696,8 +696,208 @@ pub async fn create_directory_absolute(path: String) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::store::JsonFileStore; use tempfile::tempdir; + fn make_store(dir: &tempfile::TempDir) -> JsonFileStore { + JsonFileStore::new(dir.path().join("test_store.json")).unwrap() + } + + fn make_state_with_root(path: PathBuf) -> SessionState { + let state = SessionState::default(); + { + let mut root = state.project_root.lock().unwrap(); + *root = Some(path); + } + state + } + + // --- resolve_path_impl --- + + #[test] + fn resolve_path_joins_relative_to_root() { + let root = PathBuf::from("/projects/myapp"); + let result = resolve_path_impl(root, "src/main.rs").unwrap(); + assert_eq!(result, PathBuf::from("/projects/myapp/src/main.rs")); + } + + #[test] + fn resolve_path_rejects_traversal() { + let root = PathBuf::from("/projects/myapp"); + let result = resolve_path_impl(root, "../etc/passwd"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("traversal")); + } + + // --- is_story_kit_path --- + + #[test] + fn is_story_kit_path_matches_root_and_children() { + assert!(is_story_kit_path(".story_kit")); + assert!(is_story_kit_path(".story_kit/stories/current/26.md")); + assert!(!is_story_kit_path("src/main.rs")); + assert!(!is_story_kit_path(".story_kit_other")); + } + + // --- open/close/get project --- + + #[tokio::test] + async fn open_project_sets_root_and_persists() { + let dir = tempdir().unwrap(); + let project_dir = dir.path().join("myproject"); + fs::create_dir_all(&project_dir).unwrap(); + let store = make_store(&dir); + let state = SessionState::default(); + + let result = open_project( + project_dir.to_string_lossy().to_string(), + &state, + &store, + ) + .await; + + assert!(result.is_ok()); + let root = state.get_project_root().unwrap(); + assert_eq!(root, project_dir); + } + + #[tokio::test] + async fn close_project_clears_root() { + let dir = tempdir().unwrap(); + let project_dir = dir.path().join("myproject"); + fs::create_dir_all(&project_dir).unwrap(); + let store = make_store(&dir); + let state = make_state_with_root(project_dir); + + close_project(&state, &store).unwrap(); + + let root = state.project_root.lock().unwrap(); + assert!(root.is_none()); + } + + #[tokio::test] + async fn get_current_project_returns_none_when_no_project() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + let state = SessionState::default(); + + let result = get_current_project(&state, &store).unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn get_current_project_returns_active_root() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + let state = make_state_with_root(dir.path().to_path_buf()); + + let result = get_current_project(&state, &store).unwrap(); + assert!(result.is_some()); + } + + // --- known projects --- + + #[test] + fn known_projects_empty_by_default() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + let projects = get_known_projects(&store).unwrap(); + assert!(projects.is_empty()); + } + + #[tokio::test] + async fn open_project_adds_to_known_projects() { + let dir = tempdir().unwrap(); + let project_dir = dir.path().join("proj1"); + fs::create_dir_all(&project_dir).unwrap(); + let store = make_store(&dir); + let state = SessionState::default(); + + open_project( + project_dir.to_string_lossy().to_string(), + &state, + &store, + ) + .await + .unwrap(); + + let projects = get_known_projects(&store).unwrap(); + assert_eq!(projects.len(), 1); + } + + #[test] + fn forget_known_project_removes_it() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + + store.set(KEY_KNOWN_PROJECTS, json!(["/a", "/b", "/c"])); + forget_known_project("/b".to_string(), &store).unwrap(); + + let projects = get_known_projects(&store).unwrap(); + assert_eq!(projects, vec!["/a", "/c"]); + } + + #[test] + fn forget_unknown_project_is_noop() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + + store.set(KEY_KNOWN_PROJECTS, json!(["/a"])); + forget_known_project("/nonexistent".to_string(), &store).unwrap(); + + let projects = get_known_projects(&store).unwrap(); + assert_eq!(projects, vec!["/a"]); + } + + // --- model preference --- + + #[test] + fn model_preference_none_by_default() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + assert!(get_model_preference(&store).unwrap().is_none()); + } + + #[test] + fn set_and_get_model_preference() { + let dir = tempdir().unwrap(); + let store = make_store(&dir); + set_model_preference("claude-3-sonnet".to_string(), &store).unwrap(); + assert_eq!( + get_model_preference(&store).unwrap(), + Some("claude-3-sonnet".to_string()) + ); + } + + // --- file operations --- + + #[tokio::test] + async fn read_file_impl_reads_content() { + let dir = tempdir().unwrap(); + let file = dir.path().join("test.txt"); + fs::write(&file, "hello world").unwrap(); + + let content = read_file_impl(file).await.unwrap(); + assert_eq!(content, "hello world"); + } + + #[tokio::test] + async fn read_file_impl_errors_on_missing() { + let dir = tempdir().unwrap(); + let result = read_file_impl(dir.path().join("missing.txt")).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn write_file_impl_creates_and_writes() { + let dir = tempdir().unwrap(); + let file = dir.path().join("sub").join("output.txt"); + + write_file_impl(file.clone(), "content".to_string()).await.unwrap(); + + assert_eq!(fs::read_to_string(&file).unwrap(), "content"); + } + #[tokio::test] async fn write_file_requires_approved_test_plan() { let dir = tempdir().expect("tempdir"); @@ -715,4 +915,74 @@ mod tests { "expected write to be blocked when test plan is not approved" ); } + + // --- list directory --- + + #[tokio::test] + async fn list_directory_impl_returns_sorted_entries() { + let dir = tempdir().unwrap(); + fs::create_dir(dir.path().join("zdir")).unwrap(); + fs::create_dir(dir.path().join("adir")).unwrap(); + fs::write(dir.path().join("file.txt"), "").unwrap(); + + let entries = list_directory_impl(dir.path().to_path_buf()).await.unwrap(); + + assert_eq!(entries[0].name, "adir"); + assert_eq!(entries[0].kind, "dir"); + assert_eq!(entries[1].name, "zdir"); + assert_eq!(entries[1].kind, "dir"); + assert_eq!(entries[2].name, "file.txt"); + assert_eq!(entries[2].kind, "file"); + } + + // --- validate_project_path --- + + #[tokio::test] + async fn validate_project_path_rejects_missing() { + let result = validate_project_path(PathBuf::from("/nonexistent/path")).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn validate_project_path_rejects_file() { + let dir = tempdir().unwrap(); + let file = dir.path().join("not_a_dir.txt"); + fs::write(&file, "").unwrap(); + + let result = validate_project_path(file).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn validate_project_path_accepts_directory() { + let dir = tempdir().unwrap(); + let result = validate_project_path(dir.path().to_path_buf()).await; + assert!(result.is_ok()); + } + + // --- scaffold --- + + #[test] + fn scaffold_story_kit_creates_structure() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path()).unwrap(); + + assert!(dir.path().join(".story_kit/README.md").exists()); + assert!(dir.path().join(".story_kit/specs/README.md").exists()); + assert!(dir.path().join(".story_kit/specs/00_CONTEXT.md").exists()); + assert!(dir.path().join(".story_kit/specs/tech/STACK.md").exists()); + assert!(dir.path().join(".story_kit/stories/archive").is_dir()); + } + + #[test] + fn scaffold_story_kit_does_not_overwrite_existing() { + let dir = tempdir().unwrap(); + let readme = dir.path().join(".story_kit/README.md"); + fs::create_dir_all(readme.parent().unwrap()).unwrap(); + fs::write(&readme, "custom content").unwrap(); + + scaffold_story_kit(dir.path()).unwrap(); + + assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content"); + } } diff --git a/server/src/io/shell.rs b/server/src/io/shell.rs index 82c8249..f45bc73 100644 --- a/server/src/io/shell.rs +++ b/server/src/io/shell.rs @@ -106,4 +106,64 @@ mod tests { "expected shell execution to be blocked when test plan is not approved" ); } + + #[tokio::test] + async fn exec_shell_impl_rejects_disallowed_command() { + let dir = tempdir().unwrap(); + let result = exec_shell_impl( + "curl".to_string(), + vec!["https://example.com".to_string()], + dir.path().to_path_buf(), + ) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not in the allowlist")); + } + + #[tokio::test] + async fn exec_shell_impl_runs_allowed_command() { + let dir = tempdir().unwrap(); + let result = exec_shell_impl( + "ls".to_string(), + Vec::new(), + dir.path().to_path_buf(), + ) + .await; + + assert!(result.is_ok()); + let output = result.unwrap(); + assert_eq!(output.exit_code, 0); + } + + #[tokio::test] + async fn exec_shell_impl_captures_stdout() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("hello.txt"), "").unwrap(); + + let result = exec_shell_impl( + "ls".to_string(), + Vec::new(), + dir.path().to_path_buf(), + ) + .await + .unwrap(); + + assert!(result.stdout.contains("hello.txt")); + } + + #[tokio::test] + async fn exec_shell_impl_returns_nonzero_exit_code() { + let dir = tempdir().unwrap(); + let result = exec_shell_impl( + "ls".to_string(), + vec!["nonexistent_file_xyz".to_string()], + dir.path().to_path_buf(), + ) + .await + .unwrap(); + + assert_ne!(result.exit_code, 0); + assert!(!result.stderr.is_empty()); + } } From b6e55a513fe202a7e0b36252c13aaa244e3ac74f Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Feb 2026 13:55:59 +0000 Subject: [PATCH 3/4] =?UTF-8?q?WIP:=20Batch=203=20=E2=80=94=20backfill=20f?= =?UTF-8?q?rontend=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usePathCompletion: 16 tests (isFuzzyMatch, getCurrentPartial, hook behavior) - api/client.ts: 9 tests (fetch mocks for all major endpoints, error handling) - api/workflow.ts: 6 tests (record, acceptance, review queue, ensure) Frontend tests: 13 → 44 Co-Authored-By: Claude Opus 4.6 --- frontend/src/api/client.test.ts | 138 +++++++++++++++ frontend/src/api/workflow.test.ts | 113 +++++++++++++ .../selection/usePathCompletion.test.ts | 160 ++++++++++++++++++ .../components/selection/usePathCompletion.ts | 4 +- 4 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 frontend/src/api/client.test.ts create mode 100644 frontend/src/api/workflow.test.ts create mode 100644 frontend/src/components/selection/usePathCompletion.test.ts diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts new file mode 100644 index 0000000..81f933c --- /dev/null +++ b/frontend/src/api/client.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "./client"; + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function okResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function errorResponse(status: number, text: string) { + return new Response(text, { status }); +} + +describe("api client", () => { + describe("getCurrentProject", () => { + it("sends GET to /project", async () => { + mockFetch.mockResolvedValueOnce(okResponse("/home/user/project")); + + const result = await api.getCurrentProject(); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/project", + expect.objectContaining({}), + ); + expect(result).toBe("/home/user/project"); + }); + + it("returns null when no project open", async () => { + mockFetch.mockResolvedValueOnce(okResponse(null)); + + const result = await api.getCurrentProject(); + expect(result).toBeNull(); + }); + }); + + describe("openProject", () => { + it("sends POST with path", async () => { + mockFetch.mockResolvedValueOnce(okResponse("/home/user/project")); + + await api.openProject("/home/user/project"); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/project", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ path: "/home/user/project" }), + }), + ); + }); + }); + + describe("closeProject", () => { + it("sends DELETE to /project", async () => { + mockFetch.mockResolvedValueOnce(okResponse(true)); + + await api.closeProject(); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/project", + expect.objectContaining({ method: "DELETE" }), + ); + }); + }); + + describe("getKnownProjects", () => { + it("returns array of project paths", async () => { + mockFetch.mockResolvedValueOnce(okResponse(["/a", "/b"])); + + const result = await api.getKnownProjects(); + expect(result).toEqual(["/a", "/b"]); + }); + }); + + describe("error handling", () => { + it("throws on non-ok response with body text", async () => { + mockFetch.mockResolvedValueOnce(errorResponse(404, "Not found")); + + await expect(api.getCurrentProject()).rejects.toThrow("Not found"); + }); + + it("throws with status code when no body", async () => { + mockFetch.mockResolvedValueOnce(errorResponse(500, "")); + + await expect(api.getCurrentProject()).rejects.toThrow( + "Request failed (500)", + ); + }); + }); + + describe("searchFiles", () => { + it("sends POST with query", async () => { + mockFetch.mockResolvedValueOnce( + okResponse([{ path: "src/main.rs", matches: 1 }]), + ); + + const result = await api.searchFiles("hello"); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/fs/search", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ query: "hello" }), + }), + ); + expect(result).toHaveLength(1); + }); + }); + + describe("execShell", () => { + it("sends POST with command and args", async () => { + mockFetch.mockResolvedValueOnce( + okResponse({ stdout: "output", stderr: "", exit_code: 0 }), + ); + + const result = await api.execShell("ls", ["-la"]); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/shell/exec", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ command: "ls", args: ["-la"] }), + }), + ); + expect(result.exit_code).toBe(0); + }); + }); +}); diff --git a/frontend/src/api/workflow.test.ts b/frontend/src/api/workflow.test.ts new file mode 100644 index 0000000..0d68f0c --- /dev/null +++ b/frontend/src/api/workflow.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { workflowApi } from "./workflow"; + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function okResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function errorResponse(status: number, text: string) { + return new Response(text, { status }); +} + +describe("workflowApi", () => { + describe("recordTests", () => { + it("sends POST to /workflow/tests/record", async () => { + mockFetch.mockResolvedValueOnce(okResponse(true)); + + const payload = { + story_id: "story-29", + unit: [{ name: "t1", status: "pass" as const }], + integration: [], + }; + + await workflowApi.recordTests(payload); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/workflow/tests/record", + expect.objectContaining({ method: "POST" }), + ); + }); + }); + + describe("getAcceptance", () => { + it("sends POST and returns acceptance response", async () => { + const response = { + can_accept: true, + reasons: [], + warning: null, + summary: { total: 2, passed: 2, failed: 0 }, + missing_categories: [], + }; + mockFetch.mockResolvedValueOnce(okResponse(response)); + + const result = await workflowApi.getAcceptance({ + story_id: "story-29", + }); + + expect(result.can_accept).toBe(true); + expect(result.summary.total).toBe(2); + }); + }); + + describe("getReviewQueueAll", () => { + it("sends GET to /workflow/review/all", async () => { + mockFetch.mockResolvedValueOnce(okResponse({ stories: [] })); + + const result = await workflowApi.getReviewQueueAll(); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/workflow/review/all", + expect.objectContaining({}), + ); + expect(result.stories).toEqual([]); + }); + }); + + describe("ensureAcceptance", () => { + it("returns true when acceptance passes", async () => { + mockFetch.mockResolvedValueOnce(okResponse(true)); + + const result = await workflowApi.ensureAcceptance({ + story_id: "story-29", + }); + + expect(result).toBe(true); + }); + + it("throws on error response", async () => { + mockFetch.mockResolvedValueOnce( + errorResponse(400, "Acceptance is blocked"), + ); + + await expect( + workflowApi.ensureAcceptance({ story_id: "story-29" }), + ).rejects.toThrow("Acceptance is blocked"); + }); + }); + + describe("getReviewQueue", () => { + it("sends GET to /workflow/review", async () => { + mockFetch.mockResolvedValueOnce( + okResponse({ stories: [{ story_id: "s1", can_accept: true }] }), + ); + + const result = await workflowApi.getReviewQueue(); + + expect(result.stories).toHaveLength(1); + expect(result.stories[0].story_id).toBe("s1"); + }); + }); +}); diff --git a/frontend/src/components/selection/usePathCompletion.test.ts b/frontend/src/components/selection/usePathCompletion.test.ts new file mode 100644 index 0000000..ab785f6 --- /dev/null +++ b/frontend/src/components/selection/usePathCompletion.test.ts @@ -0,0 +1,160 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { FileEntry } from "./usePathCompletion"; +import { + getCurrentPartial, + isFuzzyMatch, + usePathCompletion, +} from "./usePathCompletion"; + +describe("isFuzzyMatch", () => { + it("matches when query is empty", () => { + expect(isFuzzyMatch("anything", "")).toBe(true); + }); + + it("matches exact prefix", () => { + expect(isFuzzyMatch("Documents", "Doc")).toBe(true); + }); + + it("matches fuzzy subsequence", () => { + expect(isFuzzyMatch("Documents", "dms")).toBe(true); + }); + + it("is case insensitive", () => { + expect(isFuzzyMatch("Documents", "DOCU")).toBe(true); + }); + + it("rejects when chars not found in order", () => { + expect(isFuzzyMatch("abc", "acb")).toBe(false); + }); + + it("rejects completely unrelated", () => { + expect(isFuzzyMatch("hello", "xyz")).toBe(false); + }); +}); + +describe("getCurrentPartial", () => { + it("returns empty for empty input", () => { + expect(getCurrentPartial("")).toBe(""); + }); + + it("returns empty when input ends with slash", () => { + expect(getCurrentPartial("/home/user/")).toBe(""); + }); + + it("returns last segment", () => { + expect(getCurrentPartial("/home/user/Doc")).toBe("Doc"); + }); + + it("returns full input when no slash", () => { + expect(getCurrentPartial("Doc")).toBe("Doc"); + }); + + it("trims then evaluates: trailing-slash input returns empty", () => { + // " /home/user/ " trims to "/home/user/" which ends with slash + expect(getCurrentPartial(" /home/user/ ")).toBe(""); + }); + + it("trims then returns last segment", () => { + expect(getCurrentPartial(" /home/user/Doc ")).toBe("Doc"); + }); +}); + +describe("usePathCompletion hook", () => { + const mockListDir = vi.fn<(path: string) => Promise>(); + + beforeEach(() => { + mockListDir.mockReset(); + }); + + it("returns empty matchList for empty input", async () => { + const { result } = renderHook(() => + usePathCompletion({ + pathInput: "", + setPathInput: vi.fn(), + homeDir: "/home/user", + listDirectoryAbsolute: mockListDir, + debounceMs: 0, + }), + ); + + // Allow effect + setTimeout(0) to fire + await waitFor(() => { + expect(mockListDir).not.toHaveBeenCalled(); + }); + + expect(result.current.matchList).toEqual([]); + }); + + it("fetches directory listing and returns matches", async () => { + mockListDir.mockResolvedValue([ + { name: "Documents", kind: "dir" }, + { name: "Downloads", kind: "dir" }, + { name: ".bashrc", kind: "file" }, + ]); + + const { result } = renderHook(() => + usePathCompletion({ + pathInput: "/home/user/", + setPathInput: vi.fn(), + homeDir: "/home/user", + listDirectoryAbsolute: mockListDir, + debounceMs: 0, + }), + ); + + await waitFor(() => { + expect(result.current.matchList.length).toBe(2); + }); + + expect(result.current.matchList[0].name).toBe("Documents"); + expect(result.current.matchList[1].name).toBe("Downloads"); + expect(result.current.matchList.every((m) => m.path.endsWith("/"))).toBe( + true, + ); + }); + + it("filters by fuzzy match on partial input", async () => { + mockListDir.mockResolvedValue([ + { name: "Documents", kind: "dir" }, + { name: "Downloads", kind: "dir" }, + { name: "Desktop", kind: "dir" }, + ]); + + const { result } = renderHook(() => + usePathCompletion({ + pathInput: "/home/user/Doc", + setPathInput: vi.fn(), + homeDir: "/home/user", + listDirectoryAbsolute: mockListDir, + debounceMs: 0, + }), + ); + + await waitFor(() => { + expect(result.current.matchList.length).toBe(1); + }); + + expect(result.current.matchList[0].name).toBe("Documents"); + }); + + it("calls setPathInput when acceptMatch is invoked", () => { + const setPathInput = vi.fn(); + + const { result } = renderHook(() => + usePathCompletion({ + pathInput: "/home/", + setPathInput, + homeDir: "/home", + listDirectoryAbsolute: mockListDir, + debounceMs: 0, + }), + ); + + act(() => { + result.current.acceptMatch("/home/user/Documents/"); + }); + + expect(setPathInput).toHaveBeenCalledWith("/home/user/Documents/"); + }); +}); diff --git a/frontend/src/components/selection/usePathCompletion.ts b/frontend/src/components/selection/usePathCompletion.ts index 83468aa..d3eca77 100644 --- a/frontend/src/components/selection/usePathCompletion.ts +++ b/frontend/src/components/selection/usePathCompletion.ts @@ -30,7 +30,7 @@ export interface UsePathCompletionResult { closeSuggestions: () => void; } -function isFuzzyMatch(candidate: string, query: string) { +export function isFuzzyMatch(candidate: string, query: string) { if (!query) return true; const lowerCandidate = candidate.toLowerCase(); const lowerQuery = query.toLowerCase(); @@ -43,7 +43,7 @@ function isFuzzyMatch(candidate: string, query: string) { return true; } -function getCurrentPartial(input: string) { +export function getCurrentPartial(input: string) { const trimmed = input.trim(); if (!trimmed) return ""; if (trimmed.endsWith("/")) return ""; From f56d9e04e0ddfaf7b8946907002a9612b01049f6 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Feb 2026 14:05:57 +0000 Subject: [PATCH 4/4] =?UTF-8?q?WIP:=20Batch=204=20=E2=80=94=20App,=20GateP?= =?UTF-8?q?anel,=20ReviewPanel=20frontend=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.test.tsx | 158 +++++++++++++++++++ frontend/src/components/GatePanel.test.tsx | 145 +++++++++++++++++ frontend/src/components/ReviewPanel.test.tsx | 157 ++++++++++++++++++ 3 files changed, 460 insertions(+) create mode 100644 frontend/src/App.test.tsx create mode 100644 frontend/src/components/GatePanel.test.tsx create mode 100644 frontend/src/components/ReviewPanel.test.tsx diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..a949750 --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,158 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "./api/client"; + +vi.mock("./api/client", () => { + const api = { + getCurrentProject: vi.fn(), + getKnownProjects: vi.fn(), + getHomeDirectory: vi.fn(), + openProject: vi.fn(), + closeProject: vi.fn(), + forgetKnownProject: vi.fn(), + listDirectoryAbsolute: vi.fn(), + getOllamaModels: vi.fn(), + getAnthropicApiKeyExists: vi.fn(), + getAnthropicModels: vi.fn(), + getModelPreference: vi.fn(), + setModelPreference: vi.fn(), + cancelChat: vi.fn(), + setAnthropicApiKey: vi.fn(), + }; + class ChatWebSocket { + connect() {} + close() {} + sendChat() {} + cancel() {} + } + return { api, ChatWebSocket }; +}); + +vi.mock("./api/workflow", () => { + return { + workflowApi: { + getAcceptance: vi.fn().mockResolvedValue({ + can_accept: false, + reasons: [], + warning: null, + summary: { total: 0, passed: 0, failed: 0 }, + missing_categories: [], + }), + getReviewQueueAll: vi.fn().mockResolvedValue({ stories: [] }), + recordTests: vi.fn(), + ensureAcceptance: vi.fn(), + getReviewQueue: vi.fn(), + }, + }; +}); + +const mockedApi = vi.mocked(api); + +describe("App", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mockedApi.getKnownProjects.mockResolvedValue([]); + mockedApi.getHomeDirectory.mockResolvedValue("/home/user"); + mockedApi.listDirectoryAbsolute.mockResolvedValue([]); + mockedApi.getOllamaModels.mockResolvedValue([]); + mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false); + mockedApi.getAnthropicModels.mockResolvedValue([]); + mockedApi.getModelPreference.mockResolvedValue(null); + }); + + async function renderApp() { + const { default: App } = await import("./App"); + return render(); + } + + it("renders the selection screen when no project is open", async () => { + await renderApp(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(/\/path\/to\/project/i), + ).toBeInTheDocument(); + }); + }); + + it("populates path input with home directory", async () => { + mockedApi.getHomeDirectory.mockResolvedValue("/Users/dave"); + + await renderApp(); + + await waitFor(() => { + const input = screen.getByPlaceholderText( + /\/path\/to\/project/i, + ) as HTMLInputElement; + expect(input.value).toBe("/Users/dave/"); + }); + }); + + it("opens project and shows chat view", async () => { + mockedApi.openProject.mockResolvedValue("/home/user/myproject"); + + await renderApp(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(/\/path\/to\/project/i), + ).toBeInTheDocument(); + }); + + const input = screen.getByPlaceholderText( + /\/path\/to\/project/i, + ) as HTMLInputElement; + await userEvent.clear(input); + await userEvent.type(input, "/home/user/myproject"); + + const openButton = screen.getByRole("button", { name: /open project/i }); + await userEvent.click(openButton); + + await waitFor(() => { + expect(mockedApi.openProject).toHaveBeenCalledWith( + "/home/user/myproject", + ); + }); + }); + + it("shows error when openProject fails", async () => { + mockedApi.openProject.mockRejectedValue(new Error("Path does not exist")); + + await renderApp(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(/\/path\/to\/project/i), + ).toBeInTheDocument(); + }); + + const input = screen.getByPlaceholderText( + /\/path\/to\/project/i, + ) as HTMLInputElement; + await userEvent.clear(input); + await userEvent.type(input, "/bad/path"); + + const openButton = screen.getByRole("button", { name: /open project/i }); + await userEvent.click(openButton); + + await waitFor(() => { + expect(screen.getByText(/Path does not exist/)).toBeInTheDocument(); + }); + }); + + it("shows known projects list", async () => { + mockedApi.getKnownProjects.mockResolvedValue([ + "/home/user/project1", + "/home/user/project2", + ]); + + await renderApp(); + + await waitFor(() => { + expect(screen.getByTitle("/home/user/project1")).toBeInTheDocument(); + expect(screen.getByTitle("/home/user/project2")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/GatePanel.test.tsx b/frontend/src/components/GatePanel.test.tsx new file mode 100644 index 0000000..fa53004 --- /dev/null +++ b/frontend/src/components/GatePanel.test.tsx @@ -0,0 +1,145 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { GatePanel } from "./GatePanel"; + +const baseProps = { + gateState: null, + gateStatusLabel: "Unknown", + gateStatusColor: "#aaa", + isGateLoading: false, + gateError: null, + lastGateRefresh: null, + onRefresh: vi.fn(), +}; + +describe("GatePanel", () => { + it("shows 'no workflow data' when gateState is null", () => { + render(); + expect(screen.getByText("No workflow data yet.")).toBeInTheDocument(); + }); + + it("shows loading message when isGateLoading is true", () => { + render(); + expect( + screen.getByText("Loading workflow gates..."), + ).toBeInTheDocument(); + }); + + it("shows error with retry button", async () => { + const onRefresh = vi.fn(); + render( + , + ); + + expect(screen.getByText("Connection failed")).toBeInTheDocument(); + + const retryButton = screen.getByRole("button", { name: "Retry" }); + await userEvent.click(retryButton); + expect(onRefresh).toHaveBeenCalledOnce(); + }); + + it("shows gate status label and color", () => { + render( + , + ); + expect(screen.getByText("Blocked")).toBeInTheDocument(); + }); + + it("shows test summary when gateState is provided", () => { + render( + , + ); + expect( + screen.getByText(/5\/5 passing, 0 failing/), + ).toBeInTheDocument(); + }); + + it("shows missing categories", () => { + render( + , + ); + expect( + screen.getByText("Missing: unit, integration"), + ).toBeInTheDocument(); + }); + + it("shows warning text", () => { + render( + , + ); + expect( + screen.getByText("Multiple tests failing — fix one at a time."), + ).toBeInTheDocument(); + }); + + it("shows reasons as list items", () => { + render( + , + ); + expect( + screen.getByText("No approved test plan."), + ).toBeInTheDocument(); + expect(screen.getByText("Tests are failing.")).toBeInTheDocument(); + }); + + it("calls onRefresh when Refresh button is clicked", async () => { + const onRefresh = vi.fn(); + render(); + + await userEvent.click( + screen.getByRole("button", { name: "Refresh" }), + ); + expect(onRefresh).toHaveBeenCalledOnce(); + }); + + it("disables Refresh button when loading", () => { + render(); + expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/ReviewPanel.test.tsx b/frontend/src/components/ReviewPanel.test.tsx new file mode 100644 index 0000000..00e00bb --- /dev/null +++ b/frontend/src/components/ReviewPanel.test.tsx @@ -0,0 +1,157 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { ReviewStory } from "../api/workflow"; +import { ReviewPanel } from "./ReviewPanel"; + +const readyStory: ReviewStory = { + story_id: "29_backfill_tests", + can_accept: true, + reasons: [], + warning: null, + summary: { total: 5, passed: 5, failed: 0 }, + missing_categories: [], +}; + +const blockedStory: ReviewStory = { + story_id: "26_tdd_gates", + can_accept: false, + reasons: ["2 tests are failing."], + warning: "Multiple tests failing — fix one at a time.", + summary: { total: 5, passed: 3, failed: 2 }, + missing_categories: [], +}; + +const baseProps = { + reviewQueue: [] as ReviewStory[], + isReviewLoading: false, + reviewError: null, + proceedingStoryId: null, + storyId: "", + isGateLoading: false, + proceedError: null, + proceedSuccess: null, + lastReviewRefresh: null, + onRefresh: vi.fn(), + onProceed: vi.fn().mockResolvedValue(undefined), +}; + +describe("ReviewPanel", () => { + it("shows empty state when no stories", () => { + render(); + expect( + screen.getByText("No stories waiting for review."), + ).toBeInTheDocument(); + }); + + it("shows loading state", () => { + render(); + expect(screen.getByText("Loading review queue...")).toBeInTheDocument(); + }); + + it("shows error with retry button", async () => { + const onRefresh = vi.fn(); + render( + , + ); + + expect( + screen.getByText(/Network error.*Use Refresh to try again\./), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(onRefresh).toHaveBeenCalledOnce(); + }); + + it("renders ready story with Proceed button", () => { + render(); + + expect(screen.getByText("29_backfill_tests")).toBeInTheDocument(); + expect(screen.getByText("Ready")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Proceed" })).toBeEnabled(); + }); + + it("renders blocked story with disabled button", () => { + render(); + + expect(screen.getByText("26_tdd_gates")).toBeInTheDocument(); + expect(screen.getAllByText("Blocked")).toHaveLength(2); + expect(screen.getByRole("button", { name: "Blocked" })).toBeDisabled(); + }); + + it("shows failing badge with count", () => { + render(); + expect(screen.getByText("Failing 2")).toBeInTheDocument(); + }); + + it("shows warning badge", () => { + render(); + expect(screen.getByText("Warning")).toBeInTheDocument(); + }); + + it("shows test summary per story", () => { + render(); + expect(screen.getByText(/5\/5 passing,\s*0 failing/)).toBeInTheDocument(); + }); + + it("shows missing categories", () => { + const missingStory: ReviewStory = { + ...blockedStory, + missing_categories: ["unit", "integration"], + }; + render(); + expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument(); + }); + + it("calls onProceed when Proceed is clicked", async () => { + const onProceed = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Proceed" })); + expect(onProceed).toHaveBeenCalledWith("29_backfill_tests"); + }); + + it("shows queue counts in header", () => { + render( + , + ); + expect(screen.getByText(/1 ready \/ 2 total/)).toBeInTheDocument(); + }); + + it("shows proceedError message", () => { + render( + , + ); + expect( + screen.getByText("Acceptance blocked: tests failing"), + ).toBeInTheDocument(); + }); + + it("shows proceedSuccess message", () => { + render( + , + ); + expect(screen.getByText("Story accepted successfully")).toBeInTheDocument(); + }); + + it("shows reasons as list items", () => { + render(); + expect(screen.getByText("2 tests are failing.")).toBeInTheDocument(); + }); +});