From 2919edf3f24008a16ba3749b4ef0c7cb61e5c5e7 Mon Sep 17 00:00:00 2001 From: blindfs Date: Wed, 17 Jun 2026 09:26:27 +0800 Subject: [PATCH 1/5] test: lifecycle --- Cargo.toml | 5 ++ tests/lifecycle_tests.rs | 170 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 tests/lifecycle_tests.rs diff --git a/Cargo.toml b/Cargo.toml index afdfe03..c1cdb59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,3 +31,8 @@ simple_logger = "5.2.0" log = "0.4.32" any_ascii = "0.3.3" rstar = "0.13.0" + +[[test]] +name = "lifecycle_tests" +path = "tests/lifecycle_tests.rs" +harness = false diff --git a/tests/lifecycle_tests.rs b/tests/lifecycle_tests.rs new file mode 100644 index 0000000..f36fec8 --- /dev/null +++ b/tests/lifecycle_tests.rs @@ -0,0 +1,170 @@ +use glyphlow::{AppEngine, AppSignal, KeyListener, KeyState, Mode, config::GlyphlowConfig}; +use monio::Key; +use objc2::MainThreadMarker; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; +use std::time::Duration; +use tokio::sync::mpsc; + +fn main() { + let _mtm = MainThreadMarker::new().expect("This test must run on the main thread"); + println!("MainThreadMarker acquired successfully."); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + test_app_lifecycle_keystrokes().await; + }); + + println!("All lifecycle integration tests passed!"); +} + +async fn test_app_lifecycle_keystrokes() { + // Setup shared state + let state = Arc::new(Mutex::new(Mode::Idle)); + let key_state = Arc::new(Mutex::new(KeyState::default())); + let (tx, mut rx) = mpsc::channel::(100); + let done = Arc::new(AtomicBool::new(false)); + + // Load default config + let config = GlyphlowConfig::default(); + + // Create a temporary cache file + let temp_dir = std::env::temp_dir(); + let cache_file = temp_dir.join("glyphlow_test_tempfile.md"); + if !cache_file.exists() { + std::fs::File::create(&cache_file).unwrap(); + } + + // Create KeyListener (takes config by ref) first, then AppEngine (takes config by value) + let key_listener = KeyListener::new(tx.clone(), &config); + + let mut app_engine = AppEngine::new( + state.clone(), + key_state.clone(), + config, + cache_file.clone(), + tx.clone(), + ); + + // Spawn background thread for keystroke simulation + let sim_state = state.clone(); + let sim_key_state = key_state.clone(); + let sim_done = done.clone(); + + let sim_thread = std::thread::spawn(move || { + let wait_timeout = Duration::from_secs(2); + + // Assert initial state is Idle + assert_eq!(*sim_state.lock().unwrap(), Mode::Idle); + + // --- Step 1: Simulate the activation hotkey combination (AltLeft + G) --- + println!("[Sim] Pressing AltLeft + G..."); + { + let mut ks = sim_key_state.lock().unwrap(); + ks.key_down(&Key::AltLeft); + } + let swallowed = + key_listener.key_down(Key::KeyG, &sim_state, &mut sim_key_state.lock().unwrap()); + assert!( + swallowed, + "The global activation hotkey should be swallowed" + ); + + // Wait for state to transition to DashBoard + wait_for_state(&sim_state, Mode::DashBoard, wait_timeout); + println!("[Sim] State successfully changed to DashBoard!"); + + // --- Step 2: In DashBoard mode, press "T" (Key::KeyT) to activate Text target --- + // Reset key_state first + { + let mut ks = sim_key_state.lock().unwrap(); + ks.clear_prefix(); + ks.key_up(&Key::AltLeft); + ks.key_up(&Key::KeyG); + } + + // Wait a tiny bit for the main thread to finish processing the MenuRefresh signal + std::thread::sleep(Duration::from_millis(50)); + + println!("[Sim] Pressing T to activate Text target..."); + let swallowed = + key_listener.key_down(Key::KeyT, &sim_state, &mut sim_key_state.lock().unwrap()); + assert!(swallowed, "The menu key 'T' should be swallowed"); + + // Wait for state to transition to Filtering or WaitAndDeactivate + let next_mode = wait_for_states( + &sim_state, + &[Mode::Filtering, Mode::WaitAndDeactivate], + wait_timeout, + ); + println!("[Sim] State transitioned to {:?}", next_mode); + + // --- Step 3: Test pressing Space key to Deactivate back to Idle --- + { + let mut ks = sim_key_state.lock().unwrap(); + ks.clear_prefix(); + } + + // Wait a tiny bit for the main thread to handle the activate signal + std::thread::sleep(Duration::from_millis(50)); + + println!("[Sim] Pressing Space/Escape to deactivate..."); + let swallowed = + key_listener.key_down(Key::Escape, &sim_state, &mut sim_key_state.lock().unwrap()); + assert!(swallowed, "Deactivation key should be swallowed"); + + // Wait for state to return to Idle + wait_for_state(&sim_state, Mode::Idle, wait_timeout); + println!("[Sim] State successfully returned to Idle!"); + + sim_done.store(true, Ordering::Relaxed); + }); + + // Main thread event loop + let loop_timeout = Duration::from_secs(5); + let start_time = std::time::Instant::now(); + while !done.load(Ordering::Relaxed) { + if start_time.elapsed() > loop_timeout { + panic!("Test timed out in main event loop"); + } + + if let Ok(signal) = rx.try_recv() { + println!("[Main] Processing signal: {:?}", signal); + app_engine.handle_signal(signal).await; + } + + tokio::time::sleep(Duration::from_millis(10)).await; + } + + sim_thread.join().unwrap(); + let _ = std::fs::remove_file(cache_file); +} + +fn wait_for_state(state: &Arc>, target: Mode, timeout: Duration) { + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if *state.lock().unwrap() == target { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("Timeout waiting for state {:?}", target); +} + +fn wait_for_states(state: &Arc>, targets: &[Mode], timeout: Duration) -> Mode { + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + let current = state.lock().unwrap().clone(); + if targets.contains(¤t) { + return current; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("Timeout waiting for states {:?}", targets); +} From 42bfba4ff0e03869394b56dcf29b7cb8401593a4 Mon Sep 17 00:00:00 2001 From: blindfs Date: Wed, 17 Jun 2026 09:46:15 +0800 Subject: [PATCH 2/5] refactor --- tests/lifecycle_tests.rs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/tests/lifecycle_tests.rs b/tests/lifecycle_tests.rs index f36fec8..798d07f 100644 --- a/tests/lifecycle_tests.rs +++ b/tests/lifecycle_tests.rs @@ -10,7 +10,6 @@ use tokio::sync::mpsc; fn main() { let _mtm = MainThreadMarker::new().expect("This test must run on the main thread"); - println!("MainThreadMarker acquired successfully."); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -77,7 +76,7 @@ async fn test_app_lifecycle_keystrokes() { ); // Wait for state to transition to DashBoard - wait_for_state(&sim_state, Mode::DashBoard, wait_timeout); + wait_for_states(&sim_state, &[Mode::DashBoard], wait_timeout); println!("[Sim] State successfully changed to DashBoard!"); // --- Step 2: In DashBoard mode, press "T" (Key::KeyT) to activate Text target --- @@ -120,7 +119,7 @@ async fn test_app_lifecycle_keystrokes() { assert!(swallowed, "Deactivation key should be swallowed"); // Wait for state to return to Idle - wait_for_state(&sim_state, Mode::Idle, wait_timeout); + wait_for_states(&sim_state, &[Mode::Idle], wait_timeout); println!("[Sim] State successfully returned to Idle!"); sim_done.store(true, Ordering::Relaxed); @@ -146,17 +145,6 @@ async fn test_app_lifecycle_keystrokes() { let _ = std::fs::remove_file(cache_file); } -fn wait_for_state(state: &Arc>, target: Mode, timeout: Duration) { - let start = std::time::Instant::now(); - while start.elapsed() < timeout { - if *state.lock().unwrap() == target { - return; - } - std::thread::sleep(Duration::from_millis(10)); - } - panic!("Timeout waiting for state {:?}", target); -} - fn wait_for_states(state: &Arc>, targets: &[Mode], timeout: Duration) -> Mode { let start = std::time::Instant::now(); while start.elapsed() < timeout { From 0a0bd4b7f1bc9efefa013733403b80e2faa628de Mon Sep 17 00:00:00 2001 From: blindfs Date: Tue, 23 Jun 2026 08:38:14 +0800 Subject: [PATCH 3/5] test: modularized --- tests/lifecycle_tests.rs | 348 ++++++++++++++++++++++++++++++--------- 1 file changed, 266 insertions(+), 82 deletions(-) diff --git a/tests/lifecycle_tests.rs b/tests/lifecycle_tests.rs index 798d07f..78aeed1 100644 --- a/tests/lifecycle_tests.rs +++ b/tests/lifecycle_tests.rs @@ -1,4 +1,7 @@ -use glyphlow::{AppEngine, AppSignal, KeyListener, KeyState, Mode, config::GlyphlowConfig}; +use glyphlow::{ + AppEngine, AppSignal, FilterMode, KeyListener, KeyState, Mode, ScrollAction, TextAction, + action::text_to_clipboard, config::GlyphlowConfig, +}; use monio::Key; use objc2::MainThreadMarker; use std::sync::{ @@ -8,6 +11,24 @@ use std::sync::{ use std::time::Duration; use tokio::sync::mpsc; +#[derive(Debug, Clone)] +enum TestEvent { + // Simulates pressing a key (updates KeyState first, then calls KeyListener::key_down) + PressKey(Key), + // Simulates releasing a key (updates KeyState) + ReleaseKey(Key), + // Directly sets the application Mode (allows isolating tests for specific modes) + SetMode(Mode), + // Expects the application to transition to a specific Mode (with timeout) + ExpectMode(Mode), + // Expects a specific AppSignal to be handled by the AppEngine (with timeout) + ExpectSignal(AppSignal), + // Clears the recorded signals history + ClearSignals, + // Sets the clipboard text + SetClipboard(String), +} + fn main() { let _mtm = MainThreadMarker::new().expect("This test must run on the main thread"); @@ -17,18 +38,190 @@ fn main() { .unwrap(); rt.block_on(async { - test_app_lifecycle_keystrokes().await; + println!("Running Scenario 1: Idle -> Dashboard -> Idle (Deactivation)"); + run_test_scenario(vec![ + TestEvent::ExpectMode(Mode::Idle), + TestEvent::PressKey(Key::AltLeft), + TestEvent::PressKey(Key::KeyG), + TestEvent::ReleaseKey(Key::AltLeft), + TestEvent::ReleaseKey(Key::KeyG), + TestEvent::ExpectMode(Mode::DashBoard), + TestEvent::PressKey(Key::Escape), + TestEvent::ReleaseKey(Key::Escape), + TestEvent::ExpectMode(Mode::Idle), + ]) + .await; + + println!("Running Scenario 2: Filtering Mode Keys"); + run_test_scenario(vec![ + TestEvent::SetMode(Mode::Filtering), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::ShiftLeft), + TestEvent::ExpectSignal(AppSignal::ToggleMultiSelection), + TestEvent::ReleaseKey(Key::ShiftLeft), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::KeyA), + TestEvent::ExpectSignal(AppSignal::HintFilter('A', FilterMode::Generic)), + TestEvent::ReleaseKey(Key::KeyA), + TestEvent::PressKey(Key::Slash), + TestEvent::ExpectMode(Mode::Searching(FilterMode::Generic)), + TestEvent::ExpectSignal(AppSignal::StartSearch), + TestEvent::ReleaseKey(Key::Slash), + ]) + .await; + + println!("Running Scenario 3: Searching Mode Keys"); + run_test_scenario(vec![ + TestEvent::SetMode(Mode::Searching(FilterMode::Generic)), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::KeyB), + TestEvent::ExpectSignal(AppSignal::SearchFilter('B', FilterMode::Generic)), + TestEvent::ReleaseKey(Key::KeyB), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::Enter), + TestEvent::ExpectSignal(AppSignal::FinishSearch(FilterMode::Generic)), + TestEvent::ReleaseKey(Key::Enter), + TestEvent::SetMode(Mode::Searching(FilterMode::Generic)), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::Escape), + TestEvent::ExpectMode(Mode::Idle), + TestEvent::ExpectSignal(AppSignal::DeActivate), + TestEvent::ReleaseKey(Key::Escape), + ]) + .await; + + println!("Running Scenario 4: Scrolling Mode Keys"); + run_test_scenario(vec![ + TestEvent::SetMode(Mode::Scrolling), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::KeyJ), + TestEvent::ExpectSignal(AppSignal::ScrollAction(ScrollAction::DownRight)), + TestEvent::ReleaseKey(Key::KeyJ), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::KeyK), + TestEvent::ExpectSignal(AppSignal::ScrollAction(ScrollAction::UpLeft)), + TestEvent::ReleaseKey(Key::KeyK), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::KeyI), + TestEvent::ExpectSignal(AppSignal::ScrollAction(ScrollAction::IncreaseDistance)), + TestEvent::ReleaseKey(Key::KeyI), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::KeyD), + TestEvent::ExpectSignal(AppSignal::ScrollAction(ScrollAction::DecreaseDistance)), + TestEvent::ReleaseKey(Key::KeyD), + TestEvent::ClearSignals, + // Prefix building test: press G, then press G again to trigger GG + TestEvent::PressKey(Key::KeyG), + TestEvent::ReleaseKey(Key::KeyG), + TestEvent::PressKey(Key::KeyG), + TestEvent::ExpectSignal(AppSignal::ScrollAction(ScrollAction::Top)), + TestEvent::ReleaseKey(Key::KeyG), + ]) + .await; + + println!("Running Scenario 5: Text Action Menu Mode & Copy Action"); + run_test_scenario(vec![ + TestEvent::SetClipboard("hello text action".to_string()), + TestEvent::ExpectMode(Mode::Idle), + TestEvent::PressKey(Key::AltLeft), + TestEvent::PressKey(Key::KeyG), + TestEvent::ReleaseKey(Key::AltLeft), + TestEvent::ReleaseKey(Key::KeyG), + TestEvent::ExpectMode(Mode::DashBoard), + TestEvent::PressKey(Key::KeyC), // Read Clipboard -> transitions to TextActionMenu + TestEvent::ReleaseKey(Key::KeyC), + TestEvent::ExpectMode(Mode::TextActionMenu), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::KeyC), // Copy action + TestEvent::ExpectSignal(AppSignal::TextAction(TextAction::Copy)), + TestEvent::ReleaseKey(Key::KeyC), + ]) + .await; + + println!("Running Scenario 6: Word Picking Mode & Searching inside Word Picking"); + run_test_scenario(vec![ + TestEvent::SetClipboard("word1 word2 word3".to_string()), + TestEvent::ExpectMode(Mode::Idle), + TestEvent::PressKey(Key::AltLeft), + TestEvent::PressKey(Key::KeyG), + TestEvent::ReleaseKey(Key::AltLeft), + TestEvent::ReleaseKey(Key::KeyG), + TestEvent::ExpectMode(Mode::DashBoard), + TestEvent::PressKey(Key::KeyC), // Read Clipboard -> transitions to TextActionMenu + TestEvent::ReleaseKey(Key::KeyC), + TestEvent::ExpectMode(Mode::TextActionMenu), + TestEvent::PressKey(Key::KeyS), // Split -> transitions to WordPicking + TestEvent::ReleaseKey(Key::KeyS), + TestEvent::ExpectMode(Mode::WordPicking), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::KeyA), // Hint filter + TestEvent::ExpectSignal(AppSignal::HintFilter('A', FilterMode::WordPicking)), + TestEvent::ReleaseKey(Key::KeyA), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::Slash), // Start search + TestEvent::ExpectMode(Mode::Searching(FilterMode::WordPicking)), + TestEvent::ExpectSignal(AppSignal::StartSearch), + TestEvent::ReleaseKey(Key::Slash), + ]) + .await; + + println!("Running Scenario 7: Image Action Menu Mode"); + run_test_scenario(vec![ + TestEvent::SetMode(Mode::ImageActionMenu), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::KeyO), // Image OCR action + TestEvent::ExpectSignal(AppSignal::FrameOCR), + TestEvent::ReleaseKey(Key::KeyO), + ]) + .await; + + println!("Running Scenario 8: Dictionary Scrolling Mode"); + run_test_scenario(vec![ + TestEvent::SetMode(Mode::DictionaryScrolling), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::Backspace), // Backspace when prefix is empty -> Back to TextActionMenu + TestEvent::ExpectSignal(AppSignal::BackToTextActionMenu), + TestEvent::ReleaseKey(Key::Backspace), + ]) + .await; + + println!("Running Scenario 9: OCR Result Filtering Mode"); + run_test_scenario(vec![ + TestEvent::SetMode(Mode::OCRResultFiltering), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::KeyX), // Hint filter in OCR Result Filtering + TestEvent::ExpectSignal(AppSignal::HintFilter('X', FilterMode::OCR)), + TestEvent::ReleaseKey(Key::KeyX), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::Slash), // Start search in OCR Result Filtering + TestEvent::ExpectMode(Mode::Searching(FilterMode::OCR)), + TestEvent::ExpectSignal(AppSignal::StartSearch), + TestEvent::ReleaseKey(Key::Slash), + ]) + .await; + + println!("Running Scenario 10: Wait and Deactivate Mode"); + run_test_scenario(vec![ + TestEvent::SetMode(Mode::WaitAndDeactivate), + TestEvent::ClearSignals, + TestEvent::PressKey(Key::KeyA), // Any key should deactivate + TestEvent::ExpectMode(Mode::Idle), + TestEvent::ExpectSignal(AppSignal::DeActivate), + TestEvent::ReleaseKey(Key::KeyA), + ]) + .await; }); println!("All lifecycle integration tests passed!"); } -async fn test_app_lifecycle_keystrokes() { +async fn run_test_scenario(events: Vec) { // Setup shared state let state = Arc::new(Mutex::new(Mode::Idle)); let key_state = Arc::new(Mutex::new(KeyState::default())); let (tx, mut rx) = mpsc::channel::(100); let done = Arc::new(AtomicBool::new(false)); + let processed_signals = Arc::new(Mutex::new(Vec::new())); // Load default config let config = GlyphlowConfig::default(); @@ -40,9 +233,7 @@ async fn test_app_lifecycle_keystrokes() { std::fs::File::create(&cache_file).unwrap(); } - // Create KeyListener (takes config by ref) first, then AppEngine (takes config by value) let key_listener = KeyListener::new(tx.clone(), &config); - let mut app_engine = AppEngine::new( state.clone(), key_state.clone(), @@ -51,90 +242,95 @@ async fn test_app_lifecycle_keystrokes() { tx.clone(), ); - // Spawn background thread for keystroke simulation + // Run simulator thread concurrently let sim_state = state.clone(); let sim_key_state = key_state.clone(); + let sim_processed_signals = processed_signals.clone(); let sim_done = done.clone(); let sim_thread = std::thread::spawn(move || { - let wait_timeout = Duration::from_secs(2); - - // Assert initial state is Idle - assert_eq!(*sim_state.lock().unwrap(), Mode::Idle); - - // --- Step 1: Simulate the activation hotkey combination (AltLeft + G) --- - println!("[Sim] Pressing AltLeft + G..."); - { - let mut ks = sim_key_state.lock().unwrap(); - ks.key_down(&Key::AltLeft); - } - let swallowed = - key_listener.key_down(Key::KeyG, &sim_state, &mut sim_key_state.lock().unwrap()); - assert!( - swallowed, - "The global activation hotkey should be swallowed" - ); - - // Wait for state to transition to DashBoard - wait_for_states(&sim_state, &[Mode::DashBoard], wait_timeout); - println!("[Sim] State successfully changed to DashBoard!"); - - // --- Step 2: In DashBoard mode, press "T" (Key::KeyT) to activate Text target --- - // Reset key_state first - { - let mut ks = sim_key_state.lock().unwrap(); - ks.clear_prefix(); - ks.key_up(&Key::AltLeft); - ks.key_up(&Key::KeyG); - } + let wait_timeout = Duration::from_millis(1500); - // Wait a tiny bit for the main thread to finish processing the MenuRefresh signal - std::thread::sleep(Duration::from_millis(50)); - - println!("[Sim] Pressing T to activate Text target..."); - let swallowed = - key_listener.key_down(Key::KeyT, &sim_state, &mut sim_key_state.lock().unwrap()); - assert!(swallowed, "The menu key 'T' should be swallowed"); - - // Wait for state to transition to Filtering or WaitAndDeactivate - let next_mode = wait_for_states( - &sim_state, - &[Mode::Filtering, Mode::WaitAndDeactivate], - wait_timeout, - ); - println!("[Sim] State transitioned to {:?}", next_mode); - - // --- Step 3: Test pressing Space key to Deactivate back to Idle --- - { - let mut ks = sim_key_state.lock().unwrap(); - ks.clear_prefix(); + for (idx, event) in events.into_iter().enumerate() { + println!("[Sim] Executing event {}: {:?}", idx + 1, event); + match event { + TestEvent::PressKey(key) => { + sim_key_state.lock().unwrap().key_down(&key); + let swallowed = + key_listener.key_down(key, &sim_state, &mut sim_key_state.lock().unwrap()); + println!("[Sim] Key {:?} pressed, swallowed = {}", key, swallowed); + } + TestEvent::ReleaseKey(key) => { + sim_key_state.lock().unwrap().key_up(&key); + println!("[Sim] Key {:?} released", key); + } + TestEvent::SetMode(mode) => { + *sim_state.lock().unwrap() = mode.clone(); + println!("[Sim] Mode forced to {:?}", mode); + } + TestEvent::ExpectMode(expected_mode) => { + let start = std::time::Instant::now(); + let mut current_mode = sim_state.lock().unwrap().clone(); + while current_mode != expected_mode && start.elapsed() < wait_timeout { + std::thread::sleep(Duration::from_millis(10)); + current_mode = sim_state.lock().unwrap().clone(); + } + assert_eq!( + current_mode, expected_mode, + "Assertion failed: expected mode {:?}, but got {:?}", + expected_mode, current_mode + ); + println!("[Sim] Confirmed mode matches {:?}", expected_mode); + } + TestEvent::ExpectSignal(expected_signal) => { + let start = std::time::Instant::now(); + let mut found = false; + while start.elapsed() < wait_timeout { + if sim_processed_signals + .lock() + .unwrap() + .contains(&expected_signal) + { + found = true; + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + found, + "Assertion failed: expected signal {:?} was not processed. Processed signals: {:?}", + expected_signal, + *sim_processed_signals.lock().unwrap() + ); + println!("[Sim] Confirmed signal {:?} was processed", expected_signal); + } + TestEvent::ClearSignals => { + sim_processed_signals.lock().unwrap().clear(); + println!("[Sim] Cleared processed signals history"); + } + TestEvent::SetClipboard(text) => { + text_to_clipboard(&text); + println!("[Sim] Clipboard set to {:?}", text); + } + } + // Add a small delay between events to ensure orderly processing + std::thread::sleep(Duration::from_millis(50)); } - // Wait a tiny bit for the main thread to handle the activate signal - std::thread::sleep(Duration::from_millis(50)); - - println!("[Sim] Pressing Space/Escape to deactivate..."); - let swallowed = - key_listener.key_down(Key::Escape, &sim_state, &mut sim_key_state.lock().unwrap()); - assert!(swallowed, "Deactivation key should be swallowed"); - - // Wait for state to return to Idle - wait_for_states(&sim_state, &[Mode::Idle], wait_timeout); - println!("[Sim] State successfully returned to Idle!"); - sim_done.store(true, Ordering::Relaxed); }); // Main thread event loop - let loop_timeout = Duration::from_secs(5); + let loop_timeout = Duration::from_secs(10); let start_time = std::time::Instant::now(); while !done.load(Ordering::Relaxed) { if start_time.elapsed() > loop_timeout { - panic!("Test timed out in main event loop"); + panic!("Test timed out in main event loop waiting for simulation thread to finish"); } if let Ok(signal) = rx.try_recv() { println!("[Main] Processing signal: {:?}", signal); + processed_signals.lock().unwrap().push(signal.clone()); app_engine.handle_signal(signal).await; } @@ -144,15 +340,3 @@ async fn test_app_lifecycle_keystrokes() { sim_thread.join().unwrap(); let _ = std::fs::remove_file(cache_file); } - -fn wait_for_states(state: &Arc>, targets: &[Mode], timeout: Duration) -> Mode { - let start = std::time::Instant::now(); - while start.elapsed() < timeout { - let current = state.lock().unwrap().clone(); - if targets.contains(¤t) { - return current; - } - std::thread::sleep(Duration::from_millis(10)); - } - panic!("Timeout waiting for states {:?}", targets); -} From d429563c331003cf709f68a3ecb8f116898894dc Mon Sep 17 00:00:00 2001 From: blindfs Date: Sat, 27 Jun 2026 09:21:04 +0800 Subject: [PATCH 4/5] test: fix life cycle tests --- src/app_engine/drawing.rs | 17 ++++++++++++++--- src/app_engine/filtering.rs | 26 ++++++++++++++------------ tests/lifecycle_tests.rs | 2 +- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/app_engine/drawing.rs b/src/app_engine/drawing.rs index 53473ae..4b97eb5 100644 --- a/src/app_engine/drawing.rs +++ b/src/app_engine/drawing.rs @@ -289,9 +289,20 @@ impl AppEngine { if let Some(eoi) = self.selected.as_ref() { self.draw_element_menu(key_prefix, eoi.role(), set_mode); } else { - self.draw_dashboard(key_prefix); - // NOTE: for slow element.press() call - self.set_mode(Mode::DashBoard); + // Only fall back to DashBoard when there is no active content mode. + // Modes like Scrolling, TextActionMenu and ImageActionMenu already + // have their own drawing path via draw_element_menu; setting the + // mode to DashBoard here would corrupt their key-prefix state. + let is_dashboard_mode = self + .state + .try_lock() + .map(|g| matches!(*g, Mode::DashBoard | Mode::Idle)) + .unwrap_or(false); + if is_dashboard_mode { + self.draw_dashboard(key_prefix); + // NOTE: for slow element.press() call + self.set_mode(Mode::DashBoard); + } } } diff --git a/src/app_engine/filtering.rs b/src/app_engine/filtering.rs index 813c21c..b47a400 100644 --- a/src/app_engine/filtering.rs +++ b/src/app_engine/filtering.rs @@ -17,11 +17,11 @@ const DEBOUNCE_TIMEOUT: u64 = 150; impl AppEngine { fn ocr_res_filtering(&mut self) { if self.hint_boxes.is_empty() { + let Some(ocr_res) = self.ocr_cache.as_ref() else { + log::warn!("ocr_res_filtering called but OCR cache is not set."); + return; + }; let (digits, ocr_hints) = { - let ocr_res = self - .ocr_cache - .as_ref() - .expect("Internal Error: OCR cache not set."); let len = ocr_res.len(); let iter = ocr_res.iter().map(|(_, rect)| Frame::from_cgrect(rect)); hint_boxes_from_frames( @@ -44,10 +44,11 @@ impl AppEngine { { if self.multi_selection.is_on { if let Some((idx1, idx2)) = self.multi_selection.set_one_side(hb_idx) { - let choices: Vec<(String, Frame, bool)> = self - .ocr_cache - .as_ref() - .expect("Internal Error: OCR cache not set.") + let Some(ocr_res) = self.ocr_cache.as_ref() else { + log::warn!("ocr_res_filtering called but OCR cache is not set."); + return; + }; + let choices: Vec<(String, Frame, bool)> = ocr_res .iter() .map(|(s, rect)| (s.clone(), Frame::from_cgrect(rect), true)) .collect::>(); @@ -62,10 +63,11 @@ impl AppEngine { self.update_hints(); } } else { - let (selected_text, cg_rect) = self - .ocr_cache - .as_ref() - .expect("Internal Error: OCR cache not set.") + let Some(ocr_res) = self.ocr_cache.as_ref() else { + log::warn!("ocr_res_filtering called but OCR cache is not set."); + return; + }; + let (selected_text, cg_rect) = ocr_res .get(hb_idx) .expect("Internal Error: wrong ocr hint indexing."); let selected_text = selected_text.clone(); diff --git a/tests/lifecycle_tests.rs b/tests/lifecycle_tests.rs index 78aeed1..d7a7844 100644 --- a/tests/lifecycle_tests.rs +++ b/tests/lifecycle_tests.rs @@ -140,7 +140,7 @@ fn main() { println!("Running Scenario 6: Word Picking Mode & Searching inside Word Picking"); run_test_scenario(vec![ - TestEvent::SetClipboard("word1 word2 word3".to_string()), + TestEvent::SetClipboard("alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega one two three".to_string()), TestEvent::ExpectMode(Mode::Idle), TestEvent::PressKey(Key::AltLeft), TestEvent::PressKey(Key::KeyG), From abb85ae268290d9bfd7e1ac17b62b559d324b444 Mon Sep 17 00:00:00 2001 From: blindfs Date: Mon, 29 Jun 2026 09:13:04 +0800 Subject: [PATCH 5/5] refactor: +1 signal --- src/app_engine/drawing.rs | 17 +---------------- src/app_engine/mod.rs | 6 +++++- src/app_engine/workflow.rs | 8 +++++++- src/key_listener.rs | 3 ++- 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/app_engine/drawing.rs b/src/app_engine/drawing.rs index 4b97eb5..bddce74 100644 --- a/src/app_engine/drawing.rs +++ b/src/app_engine/drawing.rs @@ -178,7 +178,7 @@ impl AppEngine { msg } - fn draw_dashboard(&mut self, key_prefix: &str) { + pub(super) fn draw_dashboard(&mut self, key_prefix: &str) { // NOTE: need `self.last_app_window_info` for `is_workflow_valid` check, // but shouldn't set `self.selected` yet if self.selected.is_none() { @@ -288,21 +288,6 @@ impl AppEngine { pub(super) fn menu_refresh(&mut self, key_prefix: &str, set_mode: bool) { if let Some(eoi) = self.selected.as_ref() { self.draw_element_menu(key_prefix, eoi.role(), set_mode); - } else { - // Only fall back to DashBoard when there is no active content mode. - // Modes like Scrolling, TextActionMenu and ImageActionMenu already - // have their own drawing path via draw_element_menu; setting the - // mode to DashBoard here would corrupt their key-prefix state. - let is_dashboard_mode = self - .state - .try_lock() - .map(|g| matches!(*g, Mode::DashBoard | Mode::Idle)) - .unwrap_or(false); - if is_dashboard_mode { - self.draw_dashboard(key_prefix); - // NOTE: for slow element.press() call - self.set_mode(Mode::DashBoard); - } } } diff --git a/src/app_engine/mod.rs b/src/app_engine/mod.rs index 6b2fa2f..9f5d23c 100644 --- a/src/app_engine/mod.rs +++ b/src/app_engine/mod.rs @@ -154,6 +154,10 @@ impl AppEngine { AppSignal::MenuRefresh(key_prefix) => { self.menu_refresh(&key_prefix, false); } + AppSignal::DashboardRefresh(key_prefix) => { + self.draw_dashboard(&key_prefix); + self.set_mode(Mode::DashBoard); + } AppSignal::RunWorkFlow(idx) => { self.drawer.clear_menus(); self.execute_workflow(idx); @@ -229,8 +233,8 @@ impl AppEngine { if self.target == Target::ChildElement { // To act on selected parent node self.clear_cache(); + self.draw_dashboard(""); self.set_mode(Mode::DashBoard); - self.menu_refresh("", false); } } AppSignal::ScreenShot => { diff --git a/src/app_engine/workflow.rs b/src/app_engine/workflow.rs index 5058eb0..29e9878 100644 --- a/src/app_engine/workflow.rs +++ b/src/app_engine/workflow.rs @@ -1,5 +1,6 @@ use super::AppEngine; use crate::{ + Mode, ax_element::{CompiledTarget, GetAttribute, SetAttribute, Target}, config::{RoleOfInterest, WorkFlow, WorkFlowAction}, }; @@ -35,7 +36,12 @@ impl AppEngine { // Actions don't need a selected element match act { WorkFlowAction::GlyphlowMenu => { - self.menu_refresh("", true); + if self.selected.is_some() { + self.menu_refresh("", true); + } else { + self.draw_dashboard(""); + self.set_mode(Mode::DashBoard); + } // HACK: break the loop so the notification will be kept, // basically `GlyphlowMenu` should be a terminal op self.pending_workflow_actions.clear(); diff --git a/src/key_listener.rs b/src/key_listener.rs index 616f6f6..b0bff69 100644 --- a/src/key_listener.rs +++ b/src/key_listener.rs @@ -58,6 +58,7 @@ pub enum AppSignal { HintFilter(char, FilterMode), SearchFilter(char, FilterMode), MenuRefresh(String), + DashboardRefresh(String), ActOnEnter, // Sub state signals FileUpdate(PathBuf), @@ -372,7 +373,7 @@ impl KeyListener { || k.right_alternative() .is_some_and(|r| *k == r || key_state.pressed_keys.contains(&r)) }) { - self.send(AppSignal::MenuRefresh("".into())); + self.send(AppSignal::DashboardRefresh("".into())); *state = Mode::DashBoard; true } else {