diff --git a/crates/coldvox-text-injection/src/enigo_injector.rs b/crates/coldvox-text-injection/src/enigo_injector.rs index 528fffe6..34779a73 100644 --- a/crates/coldvox-text-injection/src/enigo_injector.rs +++ b/crates/coldvox-text-injection/src/enigo_injector.rs @@ -30,37 +30,39 @@ impl EnigoInjector { Enigo::new(&Settings::default()).is_ok() } + /// Inner logic for typing text, generic over the Keyboard trait for testing. + fn type_text_logic(enigo: &mut K, text: &str) -> Result<(), InjectionError> { + // Type each character with a small delay + for c in text.chars() { + match c { + ' ' => enigo + .key(Key::Space, Direction::Click) + .map_err(|e| InjectionError::MethodFailed(format!("Failed to type space: {}", e)))?, + '\n' => enigo + .key(Key::Return, Direction::Click) + .map_err(|e| InjectionError::MethodFailed(format!("Failed to type enter: {}", e)))?, + '\t' => enigo + .key(Key::Tab, Direction::Click) + .map_err(|e| InjectionError::MethodFailed(format!("Failed to type tab: {}", e)))?, + _ => { + // Use text method for all other characters + enigo + .text(&c.to_string()) + .map_err(|e| InjectionError::MethodFailed(format!("Failed to type text: {}", e)))?; + } + } + } + Ok(()) + } + /// Type text using enigo async fn type_text(&self, text: &str) -> Result<(), InjectionError> { let text_clone = text.to_string(); let result = tokio::task::spawn_blocking(move || { - let mut enigo = Enigo::new(&Settings::default()).map_err(|e| { - InjectionError::MethodFailed(format!("Failed to create Enigo: {}", e)) - })?; - - // Type each character with a small delay - for c in text_clone.chars() { - match c { - ' ' => enigo.key(Key::Space, Direction::Click).map_err(|e| { - InjectionError::MethodFailed(format!("Failed to type space: {}", e)) - })?, - '\n' => enigo.key(Key::Return, Direction::Click).map_err(|e| { - InjectionError::MethodFailed(format!("Failed to type enter: {}", e)) - })?, - '\t' => enigo.key(Key::Tab, Direction::Click).map_err(|e| { - InjectionError::MethodFailed(format!("Failed to type tab: {}", e)) - })?, - _ => { - // Use text method for all other characters - enigo.text(&c.to_string()).map_err(|e| { - InjectionError::MethodFailed(format!("Failed to type text: {}", e)) - })?; - } - } - } - - Ok(()) + let mut enigo = Enigo::new(&Settings::default()) + .map_err(|e| InjectionError::MethodFailed(format!("Failed to create Enigo: {}", e)))?; + Self::type_text_logic(&mut enigo, &text_clone) }) .await; @@ -74,44 +76,42 @@ impl EnigoInjector { } } + /// Inner logic for triggering paste, generic over the Keyboard trait for testing. + fn trigger_paste_logic(enigo: &mut K) -> Result<(), InjectionError> { + // Press platform-appropriate paste shortcut + #[cfg(target_os = "macos")] + { + enigo + .key(Key::Meta, Direction::Press) + .map_err(|e| InjectionError::MethodFailed(format!("Failed to press Cmd: {}", e)))?; + enigo + .key(Key::Unicode('v'), Direction::Click) + .map_err(|e| InjectionError::MethodFailed(format!("Failed to type 'v': {}", e)))?; + enigo + .key(Key::Meta, Direction::Release) + .map_err(|e| InjectionError::MethodFailed(format!("Failed to release Cmd: {}", e)))?; + } + #[cfg(not(target_os = "macos"))] + { + enigo + .key(Key::Control, Direction::Press) + .map_err(|e| InjectionError::MethodFailed(format!("Failed to press Ctrl: {}", e)))?; + enigo + .key(Key::Unicode('v'), Direction::Click) + .map_err(|e| InjectionError::MethodFailed(format!("Failed to type 'v': {}", e)))?; + enigo + .key(Key::Control, Direction::Release) + .map_err(|e| InjectionError::MethodFailed(format!("Failed to release Ctrl: {}", e)))?; + } + Ok(()) + } + /// Trigger paste action using enigo (Ctrl+V) async fn trigger_paste(&self) -> Result<(), InjectionError> { let result = tokio::task::spawn_blocking(|| { - let mut enigo = Enigo::new(&Settings::default()).map_err(|e| { - InjectionError::MethodFailed(format!("Failed to create Enigo: {}", e)) - })?; - - // Press platform-appropriate paste shortcut - #[cfg(target_os = "macos")] - { - enigo.key(Key::Meta, Direction::Press).map_err(|e| { - InjectionError::MethodFailed(format!("Failed to press Cmd: {}", e)) - })?; - enigo - .key(Key::Unicode('v'), Direction::Click) - .map_err(|e| { - InjectionError::MethodFailed(format!("Failed to type 'v': {}", e)) - })?; - enigo.key(Key::Meta, Direction::Release).map_err(|e| { - InjectionError::MethodFailed(format!("Failed to release Cmd: {}", e)) - })?; - } - #[cfg(not(target_os = "macos"))] - { - enigo.key(Key::Control, Direction::Press).map_err(|e| { - InjectionError::MethodFailed(format!("Failed to press Ctrl: {}", e)) - })?; - enigo - .key(Key::Unicode('v'), Direction::Click) - .map_err(|e| { - InjectionError::MethodFailed(format!("Failed to type 'v': {}", e)) - })?; - enigo.key(Key::Control, Direction::Release).map_err(|e| { - InjectionError::MethodFailed(format!("Failed to release Ctrl: {}", e)) - })?; - } - - Ok(()) + let mut enigo = Enigo::new(&Settings::default()) + .map_err(|e| InjectionError::MethodFailed(format!("Failed to create Enigo: {}", e)))?; + Self::trigger_paste_logic(&mut enigo) }) .await; @@ -179,3 +179,145 @@ impl TextInjector for EnigoInjector { ] } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::InjectionConfig; + use std::cell::RefCell; + use std::collections::VecDeque; + + // A more robust MockEnigo that can simulate failures and captures actions. + struct MockEnigo { + actions: RefCell>, + failures: RefCell>, // A queue of whether the next action should fail. + } + + impl MockEnigo { + fn new() -> Self { + Self { + actions: RefCell::new(Vec::new()), + failures: RefCell::new(VecDeque::new()), + } + } + + fn should_fail(&self) -> bool { + self.failures.borrow_mut().pop_front().unwrap_or(false) + } + + #[allow(dead_code)] + fn push_failure(&self, fail: bool) { + self.failures.borrow_mut().push_back(fail); + } + } + + impl Keyboard for MockEnigo { + fn key(&mut self, key: Key, direction: Direction) -> Result<(), enigo::Error> { + if self.should_fail() { + return Err(enigo::Error::InvalidKey); + } + self.actions + .borrow_mut() + .push(format!("key({:?},{:?})", key, direction)); + Ok(()) + } + + fn text(&mut self, text: &str) -> Result<(), enigo::Error> { + if self.should_fail() { + return Err(enigo::Error::InvalidText); + } + self.actions.borrow_mut().push(format!("text(\"{}\")", text)); + Ok(()) + } + } + + #[test] + fn test_type_text_logic_simple() { + let mut mock_enigo = MockEnigo::new(); + let result = EnigoInjector::type_text_logic(&mut mock_enigo, "abc"); + assert!(result.is_ok()); + assert_eq!( + *mock_enigo.actions.borrow(), + vec!["text(\"a\")", "text(\"b\")", "text(\"c\")"] + ); + } + + #[test] + fn test_type_text_logic_special_chars() { + let mut mock_enigo = MockEnigo::new(); + let result = EnigoInjector::type_text_logic(&mut mock_enigo, " \n\t"); + assert!(result.is_ok()); + assert_eq!( + *mock_enigo.actions.borrow(), + vec![ + "key(Space,Click)", + "key(Return,Click)", + "key(Tab,Click)" + ] + ); + } + + #[test] + fn test_type_text_logic_failure() { + let mut mock_enigo = MockEnigo::new(); + mock_enigo.push_failure(true); // First action will fail + let result = EnigoInjector::type_text_logic(&mut mock_enigo, "a"); + assert!(result.is_err()); + } + + #[test] + #[cfg(not(target_os = "macos"))] + fn test_trigger_paste_logic_non_macos() { + let mut mock_enigo = MockEnigo::new(); + let result = EnigoInjector::trigger_paste_logic(&mut mock_enigo); + assert!(result.is_ok()); + assert_eq!( + *mock_enigo.actions.borrow(), + vec![ + "key(Control,Press)", + "key(Unicode('v'),Click)", + "key(Control,Release)" + ] + ); + } + + #[test] + #[cfg(target_os = "macos")] + fn test_trigger_paste_logic_macos() { + let mut mock_enigo = MockEnigo::new(); + let result = EnigoInjector::trigger_paste_logic(&mut mock_enigo); + assert!(result.is_ok()); + assert_eq!( + *mock_enigo.actions.borrow(), + vec![ + "key(Meta,Press)", + "key(Unicode('v'),Click)", + "key(Meta,Release)" + ] + ); + } + + #[test] + fn test_trigger_paste_logic_failure() { + let mut mock_enigo = MockEnigo::new(); + mock_enigo.push_failure(true); + let result = EnigoInjector::trigger_paste_logic(&mut mock_enigo); + assert!(result.is_err()); + } + + // The async tests can remain as integration tests, but we'll keep them simple. + #[tokio::test] + async fn test_enigo_injector_new() { + let config = InjectionConfig::default(); + let injector = EnigoInjector::new(config); + assert_eq!(injector.config, config); + } + + #[tokio::test] + async fn test_inject_text_empty() { + let config = InjectionConfig::default(); + let injector = EnigoInjector::new(config); + let result = injector.inject_text("", None).await; + assert!(result.is_ok()); + } +} diff --git a/crates/coldvox-text-injection/src/kdotool_injector.rs b/crates/coldvox-text-injection/src/kdotool_injector.rs index 08318824..9b06a1ed 100644 --- a/crates/coldvox-text-injection/src/kdotool_injector.rs +++ b/crates/coldvox-text-injection/src/kdotool_injector.rs @@ -122,6 +122,158 @@ impl KdotoolInjector { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::types::InjectionConfig; + use std::env; + use std::fs; + use std::io::Write; + use std::path::PathBuf; + + // Helper to create a mock kdotool script + fn create_mock_kdotool(content: &str) -> (PathBuf, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let script_path = dir.path().join("kdotool"); + let mut file = fs::File::create(&script_path).unwrap(); + writeln!(file, "#!/bin/sh").unwrap(); + writeln!(file, "{}", content).unwrap(); + // Make it executable + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + } + (script_path, dir) + } + + // RAII guard to modify PATH for the duration of a test + struct PathGuard { + original_path: String, + } + + impl PathGuard { + fn new(temp_dir: &PathBuf) -> Self { + let original_path = env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", temp_dir.to_str().unwrap(), original_path); + env::set_var("PATH", new_path); + Self { original_path } + } + } + + impl Drop for PathGuard { + fn drop(&mut self) { + env::set_var("PATH", &self.original_path); + } + } + + #[tokio::test] + async fn test_kdotool_injector_new_available() { + let (_script, dir) = create_mock_kdotool("exit 0"); + let _guard = PathGuard::new(&dir.path().to_path_buf()); + + let config = InjectionConfig::default(); + let injector = KdotoolInjector::new(config); + assert!(injector.is_available); + } + + #[tokio::test] + async fn test_kdotool_injector_new_not_available() { + let original_path = env::var("PATH").unwrap_or_default(); + env::set_var("PATH", "/tmp/non-existent-dir"); + + let config = InjectionConfig::default(); + let injector = KdotoolInjector::new(config); + assert!(!injector.is_available); + + env::set_var("PATH", original_path); + } + + #[tokio::test] + async fn test_get_active_window_success() { + let (_script, dir) = create_mock_kdotool("echo '12345'"); + let _guard = PathGuard::new(&dir.path().to_path_buf()); + + let config = InjectionConfig::default(); + let injector = KdotoolInjector::new(config); + let window_id = injector.get_active_window().await.unwrap(); + assert_eq!(window_id, "12345"); + } + + #[tokio::test] + async fn test_get_active_window_failure() { + let (_script, dir) = create_mock_kdotool("echo 'Error' >&2; exit 1"); + let _guard = PathGuard::new(&dir.path().to_path_buf()); + + let config = InjectionConfig::default(); + let injector = KdotoolInjector::new(config); + let result = injector.get_active_window().await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_activate_window_success() { + let (_script, dir) = create_mock_kdotool("exit 0"); + let _guard = PathGuard::new(&dir.path().to_path_buf()); + + let config = InjectionConfig::default(); + let injector = KdotoolInjector::new(config); + let result = injector.activate_window("12345").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_focus_window_success() { + let (_script, dir) = create_mock_kdotool("exit 0"); + let _guard = PathGuard::new(&dir.path().to_path_buf()); + + let config = InjectionConfig::default(); + let injector = KdotoolInjector::new(config); + let result = injector.focus_window("12345").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_ensure_focus_with_id() { + let script_content = r#" + if [ "$1" = "windowfocus" ]; then + echo "focused" + elif [ "$1" = "windowactivate" ]; then + echo "activated" + fi + "#; + let (_script, dir) = create_mock_kdotool(script_content); + let _guard = PathGuard::new(&dir.path().to_path_buf()); + + let config = InjectionConfig::default(); + let injector = KdotoolInjector::new(config); + let result = injector.ensure_focus(Some("12345")).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_ensure_focus_no_id() { + let script_content = r#" + if [ "$1" = "getactivewindow" ]; then + echo "67890" + elif [ "$1" = "windowfocus" ]; then + exit 0 + elif [ "$1" = "windowactivate" ]; then + exit 0 + fi + "#; + let (_script, dir) = create_mock_kdotool(script_content); + let _guard = PathGuard::new(&dir.path().to_path_buf()); + + let config = InjectionConfig::default(); + let injector = KdotoolInjector::new(config); + let result = injector.ensure_focus(None).await; + assert!(result.is_ok()); + } +} + #[async_trait] impl TextInjector for KdotoolInjector { fn backend_name(&self) -> &'static str {