Skip to content

Commit 1812bf0

Browse files
google-labs-jules[bot]Coldaine
authored andcommitted
feat(text-injection): add unit tests for enigo and kdotool injectors
Adds comprehensive unit tests for the `enigo_injector` and `kdotool_injector` modules. For the `enigo_injector`, the code was refactored to extract the core keyboard manipulation logic into synchronous, generic functions. This allows for the use of a mock `Enigo` object, enabling isolated unit tests without side effects. For the `kdotool_injector`, the tests mock the `kdotool` command-line tool by creating a temporary script and manipulating the `PATH` environment variable. This ensures that the tests are reliable and do not depend on the actual `kdotool` binary being installed.
1 parent 0c6df4a commit 1812bf0

File tree

2 files changed

+355
-61
lines changed

2 files changed

+355
-61
lines changed

crates/coldvox-text-injection/src/enigo_injector.rs

Lines changed: 203 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -30,37 +30,39 @@ impl EnigoInjector {
3030
Enigo::new(&Settings::default()).is_ok()
3131
}
3232

33+
/// Inner logic for typing text, generic over the Keyboard trait for testing.
34+
fn type_text_logic<K: Keyboard>(enigo: &mut K, text: &str) -> Result<(), InjectionError> {
35+
// Type each character with a small delay
36+
for c in text.chars() {
37+
match c {
38+
' ' => enigo
39+
.key(Key::Space, Direction::Click)
40+
.map_err(|e| InjectionError::MethodFailed(format!("Failed to type space: {}", e)))?,
41+
'\n' => enigo
42+
.key(Key::Return, Direction::Click)
43+
.map_err(|e| InjectionError::MethodFailed(format!("Failed to type enter: {}", e)))?,
44+
'\t' => enigo
45+
.key(Key::Tab, Direction::Click)
46+
.map_err(|e| InjectionError::MethodFailed(format!("Failed to type tab: {}", e)))?,
47+
_ => {
48+
// Use text method for all other characters
49+
enigo
50+
.text(&c.to_string())
51+
.map_err(|e| InjectionError::MethodFailed(format!("Failed to type text: {}", e)))?;
52+
}
53+
}
54+
}
55+
Ok(())
56+
}
57+
3358
/// Type text using enigo
3459
async fn type_text(&self, text: &str) -> Result<(), InjectionError> {
3560
let text_clone = text.to_string();
3661

3762
let result = tokio::task::spawn_blocking(move || {
38-
let mut enigo = Enigo::new(&Settings::default()).map_err(|e| {
39-
InjectionError::MethodFailed(format!("Failed to create Enigo: {}", e))
40-
})?;
41-
42-
// Type each character with a small delay
43-
for c in text_clone.chars() {
44-
match c {
45-
' ' => enigo.key(Key::Space, Direction::Click).map_err(|e| {
46-
InjectionError::MethodFailed(format!("Failed to type space: {}", e))
47-
})?,
48-
'\n' => enigo.key(Key::Return, Direction::Click).map_err(|e| {
49-
InjectionError::MethodFailed(format!("Failed to type enter: {}", e))
50-
})?,
51-
'\t' => enigo.key(Key::Tab, Direction::Click).map_err(|e| {
52-
InjectionError::MethodFailed(format!("Failed to type tab: {}", e))
53-
})?,
54-
_ => {
55-
// Use text method for all other characters
56-
enigo.text(&c.to_string()).map_err(|e| {
57-
InjectionError::MethodFailed(format!("Failed to type text: {}", e))
58-
})?;
59-
}
60-
}
61-
}
62-
63-
Ok(())
63+
let mut enigo = Enigo::new(&Settings::default())
64+
.map_err(|e| InjectionError::MethodFailed(format!("Failed to create Enigo: {}", e)))?;
65+
Self::type_text_logic(&mut enigo, &text_clone)
6466
})
6567
.await;
6668

@@ -74,44 +76,42 @@ impl EnigoInjector {
7476
}
7577
}
7678

79+
/// Inner logic for triggering paste, generic over the Keyboard trait for testing.
80+
fn trigger_paste_logic<K: Keyboard>(enigo: &mut K) -> Result<(), InjectionError> {
81+
// Press platform-appropriate paste shortcut
82+
#[cfg(target_os = "macos")]
83+
{
84+
enigo
85+
.key(Key::Meta, Direction::Press)
86+
.map_err(|e| InjectionError::MethodFailed(format!("Failed to press Cmd: {}", e)))?;
87+
enigo
88+
.key(Key::Unicode('v'), Direction::Click)
89+
.map_err(|e| InjectionError::MethodFailed(format!("Failed to type 'v': {}", e)))?;
90+
enigo
91+
.key(Key::Meta, Direction::Release)
92+
.map_err(|e| InjectionError::MethodFailed(format!("Failed to release Cmd: {}", e)))?;
93+
}
94+
#[cfg(not(target_os = "macos"))]
95+
{
96+
enigo
97+
.key(Key::Control, Direction::Press)
98+
.map_err(|e| InjectionError::MethodFailed(format!("Failed to press Ctrl: {}", e)))?;
99+
enigo
100+
.key(Key::Unicode('v'), Direction::Click)
101+
.map_err(|e| InjectionError::MethodFailed(format!("Failed to type 'v': {}", e)))?;
102+
enigo
103+
.key(Key::Control, Direction::Release)
104+
.map_err(|e| InjectionError::MethodFailed(format!("Failed to release Ctrl: {}", e)))?;
105+
}
106+
Ok(())
107+
}
108+
77109
/// Trigger paste action using enigo (Ctrl+V)
78110
async fn trigger_paste(&self) -> Result<(), InjectionError> {
79111
let result = tokio::task::spawn_blocking(|| {
80-
let mut enigo = Enigo::new(&Settings::default()).map_err(|e| {
81-
InjectionError::MethodFailed(format!("Failed to create Enigo: {}", e))
82-
})?;
83-
84-
// Press platform-appropriate paste shortcut
85-
#[cfg(target_os = "macos")]
86-
{
87-
enigo.key(Key::Meta, Direction::Press).map_err(|e| {
88-
InjectionError::MethodFailed(format!("Failed to press Cmd: {}", e))
89-
})?;
90-
enigo
91-
.key(Key::Unicode('v'), Direction::Click)
92-
.map_err(|e| {
93-
InjectionError::MethodFailed(format!("Failed to type 'v': {}", e))
94-
})?;
95-
enigo.key(Key::Meta, Direction::Release).map_err(|e| {
96-
InjectionError::MethodFailed(format!("Failed to release Cmd: {}", e))
97-
})?;
98-
}
99-
#[cfg(not(target_os = "macos"))]
100-
{
101-
enigo.key(Key::Control, Direction::Press).map_err(|e| {
102-
InjectionError::MethodFailed(format!("Failed to press Ctrl: {}", e))
103-
})?;
104-
enigo
105-
.key(Key::Unicode('v'), Direction::Click)
106-
.map_err(|e| {
107-
InjectionError::MethodFailed(format!("Failed to type 'v': {}", e))
108-
})?;
109-
enigo.key(Key::Control, Direction::Release).map_err(|e| {
110-
InjectionError::MethodFailed(format!("Failed to release Ctrl: {}", e))
111-
})?;
112-
}
113-
114-
Ok(())
112+
let mut enigo = Enigo::new(&Settings::default())
113+
.map_err(|e| InjectionError::MethodFailed(format!("Failed to create Enigo: {}", e)))?;
114+
Self::trigger_paste_logic(&mut enigo)
115115
})
116116
.await;
117117

@@ -179,3 +179,145 @@ impl TextInjector for EnigoInjector {
179179
]
180180
}
181181
}
182+
183+
#[cfg(test)]
184+
mod tests {
185+
use super::*;
186+
use crate::types::InjectionConfig;
187+
use std::cell::RefCell;
188+
use std::collections::VecDeque;
189+
190+
// A more robust MockEnigo that can simulate failures and captures actions.
191+
struct MockEnigo {
192+
actions: RefCell<Vec<String>>,
193+
failures: RefCell<VecDeque<bool>>, // A queue of whether the next action should fail.
194+
}
195+
196+
impl MockEnigo {
197+
fn new() -> Self {
198+
Self {
199+
actions: RefCell::new(Vec::new()),
200+
failures: RefCell::new(VecDeque::new()),
201+
}
202+
}
203+
204+
fn should_fail(&self) -> bool {
205+
self.failures.borrow_mut().pop_front().unwrap_or(false)
206+
}
207+
208+
#[allow(dead_code)]
209+
fn push_failure(&self, fail: bool) {
210+
self.failures.borrow_mut().push_back(fail);
211+
}
212+
}
213+
214+
impl Keyboard for MockEnigo {
215+
fn key(&mut self, key: Key, direction: Direction) -> Result<(), enigo::Error> {
216+
if self.should_fail() {
217+
return Err(enigo::Error::InvalidKey);
218+
}
219+
self.actions
220+
.borrow_mut()
221+
.push(format!("key({:?},{:?})", key, direction));
222+
Ok(())
223+
}
224+
225+
fn text(&mut self, text: &str) -> Result<(), enigo::Error> {
226+
if self.should_fail() {
227+
return Err(enigo::Error::InvalidText);
228+
}
229+
self.actions.borrow_mut().push(format!("text(\"{}\")", text));
230+
Ok(())
231+
}
232+
}
233+
234+
#[test]
235+
fn test_type_text_logic_simple() {
236+
let mut mock_enigo = MockEnigo::new();
237+
let result = EnigoInjector::type_text_logic(&mut mock_enigo, "abc");
238+
assert!(result.is_ok());
239+
assert_eq!(
240+
*mock_enigo.actions.borrow(),
241+
vec!["text(\"a\")", "text(\"b\")", "text(\"c\")"]
242+
);
243+
}
244+
245+
#[test]
246+
fn test_type_text_logic_special_chars() {
247+
let mut mock_enigo = MockEnigo::new();
248+
let result = EnigoInjector::type_text_logic(&mut mock_enigo, " \n\t");
249+
assert!(result.is_ok());
250+
assert_eq!(
251+
*mock_enigo.actions.borrow(),
252+
vec![
253+
"key(Space,Click)",
254+
"key(Return,Click)",
255+
"key(Tab,Click)"
256+
]
257+
);
258+
}
259+
260+
#[test]
261+
fn test_type_text_logic_failure() {
262+
let mut mock_enigo = MockEnigo::new();
263+
mock_enigo.push_failure(true); // First action will fail
264+
let result = EnigoInjector::type_text_logic(&mut mock_enigo, "a");
265+
assert!(result.is_err());
266+
}
267+
268+
#[test]
269+
#[cfg(not(target_os = "macos"))]
270+
fn test_trigger_paste_logic_non_macos() {
271+
let mut mock_enigo = MockEnigo::new();
272+
let result = EnigoInjector::trigger_paste_logic(&mut mock_enigo);
273+
assert!(result.is_ok());
274+
assert_eq!(
275+
*mock_enigo.actions.borrow(),
276+
vec![
277+
"key(Control,Press)",
278+
"key(Unicode('v'),Click)",
279+
"key(Control,Release)"
280+
]
281+
);
282+
}
283+
284+
#[test]
285+
#[cfg(target_os = "macos")]
286+
fn test_trigger_paste_logic_macos() {
287+
let mut mock_enigo = MockEnigo::new();
288+
let result = EnigoInjector::trigger_paste_logic(&mut mock_enigo);
289+
assert!(result.is_ok());
290+
assert_eq!(
291+
*mock_enigo.actions.borrow(),
292+
vec![
293+
"key(Meta,Press)",
294+
"key(Unicode('v'),Click)",
295+
"key(Meta,Release)"
296+
]
297+
);
298+
}
299+
300+
#[test]
301+
fn test_trigger_paste_logic_failure() {
302+
let mut mock_enigo = MockEnigo::new();
303+
mock_enigo.push_failure(true);
304+
let result = EnigoInjector::trigger_paste_logic(&mut mock_enigo);
305+
assert!(result.is_err());
306+
}
307+
308+
// The async tests can remain as integration tests, but we'll keep them simple.
309+
#[tokio::test]
310+
async fn test_enigo_injector_new() {
311+
let config = InjectionConfig::default();
312+
let injector = EnigoInjector::new(config);
313+
assert_eq!(injector.config, config);
314+
}
315+
316+
#[tokio::test]
317+
async fn test_inject_text_empty() {
318+
let config = InjectionConfig::default();
319+
let injector = EnigoInjector::new(config);
320+
let result = injector.inject_text("", None).await;
321+
assert!(result.is_ok());
322+
}
323+
}

0 commit comments

Comments
 (0)