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/src/app_engine/drawing.rs b/src/app_engine/drawing.rs index 53473ae..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,10 +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 { - 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/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 { diff --git a/tests/lifecycle_tests.rs b/tests/lifecycle_tests.rs new file mode 100644 index 0000000..d7a7844 --- /dev/null +++ b/tests/lifecycle_tests.rs @@ -0,0 +1,342 @@ +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::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; +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"); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + 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("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), + 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 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(); + + // 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(); + } + + 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(), + ); + + // 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_millis(1500); + + 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)); + } + + sim_done.store(true, Ordering::Relaxed); + }); + + // Main thread event loop + 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 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; + } + + tokio::time::sleep(Duration::from_millis(10)).await; + } + + sim_thread.join().unwrap(); + let _ = std::fs::remove_file(cache_file); +}