From 88e8418982ba39a635fcad8247dff04b0bac55b1 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Fri, 8 May 2026 15:18:40 +0800 Subject: [PATCH 1/3] Make backend regressions visible in CI Issue #295 calls out that core Rust paths have been too dependent on manual app runs. Keep the change narrow by covering existing pure state/audio/clipboard helpers instead of refactoring coordinator or touching hardware-facing code, then wire the existing lib test suite into the lightweight CI lane. Constraint: Global hotkey, recorder, and insertion integration still require real OS permissions and devices. Rejected: Split coordinator before adding tests | broader refactor would raise review risk and is tracked separately. Confidence: high Scope-risk: narrow Directive: Keep Rust CI on --lib unless integration tests get explicit OS fixtures. Tested: npm run build Tested: cargo test --manifest-path src-tauri/Cargo.toml --lib Tested: cargo check --manifest-path src-tauri/Cargo.toml Not-tested: Live microphone, global hotkey, clipboard, and insertion device paths. Related: #295 --- .github/workflows/ci.yml | 3 + AGENTS.md | 2 +- CLAUDE.md | 2 +- openless-all/app/src-tauri/src/coordinator.rs | 68 ++++++++++++++++ openless-all/app/src-tauri/src/hotkey.rs | 50 +++++++++++- openless-all/app/src-tauri/src/insertion.rs | 11 +++ openless-all/app/src-tauri/src/recorder.rs | 77 +++++++++++++++++++ 7 files changed, 207 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91316265..0baf87fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,9 @@ jobs: - name: Check Tauri backend (cargo check) run: cargo check --manifest-path src-tauri/Cargo.toml + - name: Run Rust backend unit tests + run: cargo test --manifest-path src-tauri/Cargo.toml --lib + - name: Verify version sync across all 5 files # 两个平台都跑这个校验:Windows runner 自带 git-bash,跨 shell 表现一致。 # 一旦版本号 drift 立刻 fail,避免发版时再发现漏改。 diff --git a/AGENTS.md b/AGENTS.md index 415d0cce..15145daf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ Generated artifacts: Logs: `~/Library/Logs/OpenLess/openless.log` (macOS) / `%LOCALAPPDATA%\OpenLess\Logs\openless.log` (Windows). -There is no test runner wired in for the frontend. `src/lib/providerSetup.test.ts` is a hand-rolled assertion script — run with `npx tsx src/lib/providerSetup.test.ts` if you need it. Rust side has no `cargo test` targets yet; behavior is verified by running the app. +There is no test runner wired in for the frontend. `src/lib/providerSetup.test.ts` is a hand-rolled assertion script — run with `npx tsx src/lib/providerSetup.test.ts` if you need it. Rust backend unit tests are run with `cargo test --manifest-path src-tauri/Cargo.toml --lib`; hardware / OS-integration behavior is still verified by running the app. ## Architecture diff --git a/CLAUDE.md b/CLAUDE.md index 3fd6da4d..d99ec085 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ Generated artifacts: Logs: `~/Library/Logs/OpenLess/openless.log` (macOS) / `%LOCALAPPDATA%\OpenLess\Logs\openless.log` (Windows). -There is no test runner wired in for the frontend. `src/lib/providerSetup.test.ts` is a hand-rolled assertion script — run with `npx tsx src/lib/providerSetup.test.ts` if you need it. Rust side has no `cargo test` targets yet; behavior is verified by running the app. +There is no test runner wired in for the frontend. `src/lib/providerSetup.test.ts` is a hand-rolled assertion script — run with `npx tsx src/lib/providerSetup.test.ts` if you need it. Rust backend unit tests are run with `cargo test --manifest-path src-tauri/Cargo.toml --lib`; hardware / OS-integration behavior is still verified by running the app. ## Architecture diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 77ecc506..2bc1b26d 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -4483,6 +4483,74 @@ mod tests { ); } + #[test] + fn startup_race_check_is_table_driven_for_begin_session_edges() { + let cases = [ + ( + SessionPhase::Starting, + false, + session_id(7), + StartupRaceStatus::ActiveStarting, + ), + ( + SessionPhase::Starting, + true, + session_id(7), + StartupRaceStatus::CancelRaced, + ), + ( + SessionPhase::Idle, + false, + session_id(7), + StartupRaceStatus::CancelRaced, + ), + ( + SessionPhase::Listening, + false, + session_id(7), + StartupRaceStatus::CancelRaced, + ), + ( + SessionPhase::Starting, + false, + session_id(8), + StartupRaceStatus::StaleContinuation, + ), + ]; + + for (phase, cancelled, actual_session_id, expected) in cases { + let mut state = SessionState::default(); + state.phase = phase; + state.cancelled = cancelled; + state.session_id = actual_session_id; + + assert_eq!( + startup_race_status(&state, session_id(7)), + expected, + "phase={phase:?} cancelled={cancelled} actual_session={actual_session_id}" + ); + } + } + + #[test] + fn begin_recording_abort_is_noop_after_prior_cancel_or_idle() { + let cases = [ + (SessionPhase::Idle, false), + (SessionPhase::Processing, false), + (SessionPhase::Listening, true), + ]; + + for (phase, cancelled) in cases { + let mut state = SessionState::default(); + state.phase = phase; + state.cancelled = cancelled; + + assert!(begin_recording_abort_before_restore(&mut state).is_none()); + assert_eq!(state.phase, phase); + assert_eq!(state.cancelled, cancelled); + } + } + #[test] fn stale_startup_cleanup_keeps_newer_asr_resource() { let coordinator = Coordinator::new(); diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 0692c3e1..afbf8c66 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -36,9 +36,8 @@ mod tests { use super::*; use std::sync::atomic::Ordering; - #[test] - fn reset_shared_held_state_clears_all_shortcut_latches() { - let shared = Shared { + fn shared_with_held_latches() -> Shared { + Shared { binding: RwLock::new(HotkeyBinding::default()), trigger_held: AtomicBool::new(true), qa_trigger: RwLock::new(None), @@ -46,8 +45,12 @@ mod tests { translation_trigger: RwLock::new(None), translation_trigger_held: AtomicBool::new(true), translation_modifier_held: AtomicBool::new(true), - }; + } + } + #[test] + fn reset_shared_held_state_clears_all_shortcut_latches() { + let shared = shared_with_held_latches(); reset_shared_held_state(&shared); assert!(!shared.trigger_held.load(Ordering::SeqCst)); @@ -55,6 +58,45 @@ mod tests { assert!(!shared.translation_trigger_held.load(Ordering::SeqCst)); assert!(!shared.translation_modifier_held.load(Ordering::SeqCst)); } + + #[test] + fn update_binding_resets_only_dictation_latch() { + let shared = shared_with_held_latches(); + let next = HotkeyBinding { + trigger: HotkeyTrigger::LeftControl, + mode: crate::types::HotkeyMode::Hold, + keys: None, + }; + + update_shared_binding(&shared, next.clone()); + + assert_eq!(*shared.binding.read(), next); + assert!(!shared.trigger_held.load(Ordering::SeqCst)); + assert!(shared.qa_trigger_held.load(Ordering::SeqCst)); + assert!(shared.translation_trigger_held.load(Ordering::SeqCst)); + assert!(shared.translation_modifier_held.load(Ordering::SeqCst)); + } + + #[test] + fn update_modifier_shortcuts_resets_only_modifier_latches() { + let shared = shared_with_held_latches(); + + update_shared_modifier_shortcuts( + &shared, + Some(HotkeyTrigger::RightCommand), + Some(HotkeyTrigger::LeftOption), + ); + + assert_eq!(*shared.qa_trigger.read(), Some(HotkeyTrigger::RightCommand)); + assert_eq!( + *shared.translation_trigger.read(), + Some(HotkeyTrigger::LeftOption) + ); + assert!(shared.trigger_held.load(Ordering::SeqCst)); + assert!(!shared.qa_trigger_held.load(Ordering::SeqCst)); + assert!(!shared.translation_trigger_held.load(Ordering::SeqCst)); + assert!(shared.translation_modifier_held.load(Ordering::SeqCst)); + } } pub trait HotkeyAdapter: Send + Sync { diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 034a8e28..04fee283 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -439,8 +439,11 @@ mod macos { #[cfg(test)] mod tests { use super::*; + #[cfg(target_os = "windows")] use std::sync::{Arc, Mutex}; + #[cfg(target_os = "windows")] use std::thread; + #[cfg(target_os = "windows")] use std::time::Duration; #[test] @@ -457,6 +460,14 @@ mod tests { assert!(!should_restore_clipboard(None, "dictated text")); } + #[test] + fn empty_insertions_never_touch_clipboard_or_paste_path() { + let inserter = TextInserter::new(); + + assert_eq!(inserter.insert("", true), InsertStatus::CopiedFallback); + assert_eq!(inserter.copy_fallback(""), InsertStatus::CopiedFallback); + } + #[test] #[cfg(target_os = "windows")] fn delayed_terminal_paste_must_see_dictated_text_before_clipboard_restore() { diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index e2f6aba1..c135a115 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -602,3 +602,80 @@ fn update_peak(slot: &AtomicUsize, current: f32) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex as StdMutex}; + + #[derive(Default)] + struct RecordingConsumer { + chunks: StdMutex>>, + } + + impl AudioConsumer for RecordingConsumer { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + self.chunks.lock().unwrap().push(pcm.to_vec()); + } + } + + #[test] + fn downmix_to_mono_averages_complete_interleaved_frames() { + let mono = downmix_to_mono(&[1.0, -1.0, 0.5, 0.25, 0.0], 2); + + assert_eq!(mono, vec![0.0, 0.375]); + } + + #[test] + fn quantize_to_i16_le_clamps_and_reports_rms() { + let (bytes, rms) = quantize_to_i16_le(&[-2.0, 0.0, 0.5, 2.0]); + let samples = bytes + .chunks_exact(2) + .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + + assert_eq!(samples, vec![-32767, 0, 16383, 32767]); + assert!((rms - 0.75).abs() < 0.0001); + } + + #[test] + fn resample_passthrough_updates_tail_sample_without_phase_drift() { + let state = StreamState::new(); + *state.resample_phase.lock() = 0.5; + + let out = resample_to_target( + &[0.1, -0.2, 0.3], + TARGET_SAMPLE_RATE, + TARGET_SAMPLE_RATE, + &state, + ); + + assert_eq!(out, vec![0.1, -0.2, 0.3]); + assert_eq!(*state.last_sample.lock(), 0.3); + assert_eq!(*state.resample_phase.lock(), 0.5); + } + + #[test] + fn process_callback_emits_pcm_level_and_liveness_marker() { + let consumer = RecordingConsumer::default(); + let levels = Arc::new(StdMutex::new(Vec::new())); + let levels_for_handler = Arc::clone(&levels); + let state = StreamState::new(); + + process_callback( + &[0.25, -0.25], + 1, + TARGET_SAMPLE_RATE, + &consumer, + &move |level| levels_for_handler.lock().unwrap().push(level), + &state, + ); + + let chunks = consumer.chunks.lock().unwrap(); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].len(), 4); + assert_eq!(*levels.lock().unwrap(), vec![1.0]); + assert!(state.last_callback_time.lock().is_some()); + assert_eq!(state.callback_count.load(Ordering::Relaxed), 1); + } +} From b08280515835cbc3c04e47b3fa7de648b800a7dd Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Fri, 8 May 2026 15:35:34 +0800 Subject: [PATCH 2/3] Close backend test blind spots from review The first #295 pass made cargo tests visible but still left the reviewer-requested state-machine, modifier-edge, and insertion fallback contracts too implicit. This adds narrow unit coverage around those contracts without refactoring runtime flow.\n\nConstraint: Address review compliance with minimal production changes.\nRejected: Split coordinator or hotkey adapters for testability | broader than the issue and riskier for the active PR.\nConfidence: high\nScope-risk: narrow\nTested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib; cargo check --manifest-path src-tauri/Cargo.toml; npm run build\nNot-tested: Live macOS/Windows native hotkey hooks and clipboard/AX behavior. --- openless-all/app/src-tauri/src/coordinator.rs | 94 ++++++ openless-all/app/src-tauri/src/hotkey.rs | 270 +++++++++++++++++- openless-all/app/src-tauri/src/insertion.rs | 81 +++++- 3 files changed, 435 insertions(+), 10 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 2bc1b26d..fd85a4cf 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -4047,6 +4047,9 @@ fn resolve_ark_endpoint_with_policy( mod tests { use super::*; use crate::types::HotkeyTrigger; + use once_cell::sync::Lazy; + + static ENV_LOCK: Lazy> = Lazy::new(|| tokio::sync::Mutex::new(())); fn session_id(n: u128) -> SessionId { Uuid::from_u128(n) @@ -4058,6 +4061,7 @@ mod tests { .filter_level(log::LevelFilter::Info) .is_test(false) .try_init(); + let _guard = ENV_LOCK.lock().await; std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); let coordinator = Coordinator::new(); @@ -4067,6 +4071,52 @@ mod tests { std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); } + #[tokio::test] + async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + let old_session_id = coordinator.inner.state.lock().session_id; + { + let mut state = coordinator.inner.state.lock(); + state.pending_stop = true; + state.cancelled = true; + } + + coordinator.start_dictation().await.unwrap(); + + let state = coordinator.inner.state.lock(); + assert_eq!(state.phase, SessionPhase::Listening); + assert!(!state.pending_stop); + assert!(!state.cancelled); + assert_ne!(state.session_id, old_session_id); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + + #[tokio::test] + async fn begin_session_ignores_non_idle_phase() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + let old_session_id = { + let mut state = coordinator.inner.state.lock(); + state.phase = SessionPhase::Processing; + state.session_id = session_id(99); + state.session_id + }; + + coordinator.start_dictation().await.unwrap(); + + let state = coordinator.inner.state.lock(); + assert_eq!(state.phase, SessionPhase::Processing); + assert_eq!(state.session_id, old_session_id); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + #[test] fn window_key_matcher_mirrors_windows_trigger_aliases() { let cases = [ @@ -4245,6 +4295,50 @@ mod tests { assert!(state.pending_stop); } + #[tokio::test] + async fn stop_dictation_from_listening_without_asr_returns_idle() { + let coordinator = Coordinator::new(); + { + let mut state = coordinator.inner.state.lock(); + state.phase = SessionPhase::Listening; + state.session_id = session_id(123); + } + + coordinator.stop_dictation().await.unwrap(); + + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + } + + #[test] + fn cancel_session_state_machine_is_table_driven() { + let cases = [ + (SessionPhase::Idle, SessionPhase::Idle, false), + (SessionPhase::Starting, SessionPhase::Idle, true), + (SessionPhase::Listening, SessionPhase::Idle, true), + (SessionPhase::Processing, SessionPhase::Processing, true), + (SessionPhase::Inserting, SessionPhase::Inserting, false), + ]; + + for (initial, expected_phase, expected_cancelled) in cases { + let coordinator = Coordinator::new(); + { + let mut state = coordinator.inner.state.lock(); + state.phase = initial; + state.cancelled = false; + state.focus_target = Some(1); + } + + coordinator.cancel_dictation(); + + let state = coordinator.inner.state.lock(); + assert_eq!(state.phase, expected_phase, "initial={initial:?}"); + assert_eq!(state.cancelled, expected_cancelled, "initial={initial:?}"); + if matches!(initial, SessionPhase::Starting | SessionPhase::Listening) { + assert!(state.focus_target.is_none(), "initial={initial:?}"); + } + } + } + #[test] fn recorder_runtime_error_aborts_active_session() { let coordinator = Coordinator::new(); diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index afbf8c66..38bc516f 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -20,7 +20,7 @@ use parking_lot::RwLock; use crate::types::HotkeyTrigger; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyInstallError}; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HotkeyEvent { Pressed, Released, @@ -592,6 +592,93 @@ mod platform { HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), } } + + #[cfg(test)] + mod tests { + use super::*; + use parking_lot::RwLock; + use std::sync::atomic::AtomicBool; + use std::sync::mpsc; + + fn shared(trigger: HotkeyTrigger) -> Arc { + Arc::new(Shared { + binding: RwLock::new(HotkeyBinding { + trigger, + mode: crate::types::HotkeyMode::Toggle, + keys: None, + }), + trigger_held: AtomicBool::new(false), + qa_trigger: RwLock::new(None), + qa_trigger_held: AtomicBool::new(false), + translation_trigger: RwLock::new(None), + translation_trigger_held: AtomicBool::new(false), + translation_modifier_held: AtomicBool::new(false), + }) + } + + fn callback_context(shared: Arc) -> (CallbackContext, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(); + ( + CallbackContext { + shared, + tx, + tap: std::sync::Mutex::new(None), + }, + rx, + ) + } + + fn drain(rx: &mpsc::Receiver) -> Vec { + rx.try_iter().collect() + } + + #[test] + fn mac_optional_modifier_edges_are_deduped_from_mock_flags() { + let shared = shared(HotkeyTrigger::RightControl); + let (ctx, rx) = callback_context(Arc::clone(&shared)); + + handle_optional_modifier_trigger( + &ctx, + trigger_to_keycode(HotkeyTrigger::RightCommand), + trigger_to_flag_mask(HotkeyTrigger::RightCommand), + Some(HotkeyTrigger::RightCommand), + &shared.qa_trigger_held, + HotkeyEvent::QaShortcutPressed, + ); + handle_optional_modifier_trigger( + &ctx, + trigger_to_keycode(HotkeyTrigger::RightCommand), + trigger_to_flag_mask(HotkeyTrigger::RightCommand), + Some(HotkeyTrigger::RightCommand), + &shared.qa_trigger_held, + HotkeyEvent::QaShortcutPressed, + ); + handle_optional_modifier_trigger( + &ctx, + trigger_to_keycode(HotkeyTrigger::RightCommand), + 0, + Some(HotkeyTrigger::RightCommand), + &shared.qa_trigger_held, + HotkeyEvent::QaShortcutPressed, + ); + handle_optional_modifier_trigger( + &ctx, + trigger_to_keycode(HotkeyTrigger::RightCommand), + trigger_to_flag_mask(HotkeyTrigger::RightCommand), + Some(HotkeyTrigger::RightCommand), + &shared.qa_trigger_held, + HotkeyEvent::QaShortcutPressed, + ); + + assert_eq!( + drain(&rx), + vec![ + HotkeyEvent::QaShortcutPressed, + HotkeyEvent::QaShortcutPressed, + ] + ); + } + } } // ─────────────────────────── Windows implementation ─────────────────────────── @@ -899,6 +986,88 @@ mod platform { fn accept_injected_events() -> bool { std::env::var(ACCEPT_INJECTED_ENV).ok().as_deref() == Some("1") } + + #[cfg(test)] + mod tests { + use super::*; + use parking_lot::RwLock; + use std::sync::atomic::AtomicBool; + use std::sync::mpsc; + + fn shared(trigger: HotkeyTrigger) -> Arc { + Arc::new(Shared { + binding: RwLock::new(HotkeyBinding { + trigger, + mode: crate::types::HotkeyMode::Toggle, + keys: None, + }), + trigger_held: AtomicBool::new(false), + qa_trigger: RwLock::new(None), + qa_trigger_held: AtomicBool::new(false), + translation_trigger: RwLock::new(None), + translation_trigger_held: AtomicBool::new(false), + translation_modifier_held: AtomicBool::new(false), + }) + } + + fn callback_context(shared: Arc) -> (CallbackContext, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(); + ( + CallbackContext { + shared, + tx, + hook: std::sync::Mutex::new(None), + }, + rx, + ) + } + + fn drain(rx: &mpsc::Receiver) -> Vec { + rx.try_iter().collect() + } + + #[test] + fn windows_modifier_edges_are_deduped_from_mock_hook_events() { + let shared = shared(HotkeyTrigger::RightControl); + let (ctx, rx) = callback_context(shared); + + assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYDOWN)); + assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYDOWN)); + assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYUP)); + assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYUP)); + + assert_eq!( + drain(&rx), + vec![HotkeyEvent::Pressed, HotkeyEvent::Released] + ); + } + + #[test] + fn windows_optional_modifier_shortcuts_use_independent_latches() { + let shared = shared(HotkeyTrigger::RightControl); + *shared.qa_trigger.write() = Some(HotkeyTrigger::RightCommand); + *shared.translation_trigger.write() = Some(HotkeyTrigger::LeftOption); + let (ctx, rx) = callback_context(shared); + + dispatch_keyboard_event(&ctx, VK_RWIN, WM_KEYDOWN); + dispatch_keyboard_event(&ctx, VK_RWIN, WM_KEYDOWN); + dispatch_keyboard_event(&ctx, VK_RMENU, WM_KEYDOWN); + dispatch_keyboard_event(&ctx, VK_LSHIFT, WM_KEYDOWN); + dispatch_keyboard_event(&ctx, VK_LSHIFT, WM_KEYDOWN); + dispatch_keyboard_event(&ctx, VK_RWIN, WM_KEYUP); + dispatch_keyboard_event(&ctx, VK_RWIN, WM_KEYDOWN); + + assert_eq!( + drain(&rx), + vec![ + HotkeyEvent::QaShortcutPressed, + HotkeyEvent::TranslationModifierPressed, + HotkeyEvent::TranslationModifierPressed, + HotkeyEvent::QaShortcutPressed, + ] + ); + } + } } // ─────────────────────────── Linux / other implementation ─────────────────────────── @@ -1114,4 +1283,103 @@ mod platform { HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), } } + + #[cfg(test)] + mod tests { + use super::*; + use parking_lot::RwLock; + use std::sync::atomic::AtomicBool; + use std::sync::mpsc; + use std::time::SystemTime; + + fn shared(trigger: HotkeyTrigger) -> Shared { + Shared { + binding: RwLock::new(HotkeyBinding { + trigger, + mode: crate::types::HotkeyMode::Toggle, + keys: None, + }), + trigger_held: AtomicBool::new(false), + qa_trigger: RwLock::new(None), + qa_trigger_held: AtomicBool::new(false), + translation_trigger: RwLock::new(None), + translation_trigger_held: AtomicBool::new(false), + translation_modifier_held: AtomicBool::new(false), + } + } + + fn key_event(event_type: EventType) -> Event { + Event { + time: SystemTime::UNIX_EPOCH, + name: None, + event_type, + } + } + + fn drain(rx: &mpsc::Receiver) -> Vec { + rx.try_iter().collect() + } + + #[test] + fn rdev_modifier_edges_are_deduped_from_mock_events() { + let shared = shared(HotkeyTrigger::RightControl); + let (tx, rx) = mpsc::channel(); + + dispatch_event( + &shared, + &tx, + key_event(EventType::KeyPress(Key::ControlRight)), + ); + dispatch_event( + &shared, + &tx, + key_event(EventType::KeyPress(Key::ControlRight)), + ); + dispatch_event( + &shared, + &tx, + key_event(EventType::KeyRelease(Key::ControlRight)), + ); + dispatch_event( + &shared, + &tx, + key_event(EventType::KeyRelease(Key::ControlRight)), + ); + + assert_eq!( + drain(&rx), + vec![HotkeyEvent::Pressed, HotkeyEvent::Released] + ); + } + + #[test] + fn rdev_optional_modifier_shortcuts_use_independent_latches() { + let shared = shared(HotkeyTrigger::RightControl); + *shared.qa_trigger.write() = Some(HotkeyTrigger::RightCommand); + *shared.translation_trigger.write() = Some(HotkeyTrigger::LeftOption); + let (tx, rx) = mpsc::channel(); + + dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::MetaRight))); + dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::MetaRight))); + dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::Alt))); + dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::ShiftLeft))); + dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::ShiftLeft))); + dispatch_event( + &shared, + &tx, + key_event(EventType::KeyRelease(Key::MetaRight)), + ); + dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::MetaRight))); + + assert_eq!( + drain(&rx), + vec![ + HotkeyEvent::QaShortcutPressed, + HotkeyEvent::TranslationModifierPressed, + HotkeyEvent::TranslationModifierPressed, + HotkeyEvent::QaShortcutPressed, + ] + ); + } + } } diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 04fee283..da36ccd4 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -78,11 +78,7 @@ impl TextInserter { if !copy_to_clipboard(text) { return InsertStatus::Failed; } - if let Err(err) = simulate_paste() { - log::warn!("[insertion] simulated paste failed: {}", err); - return InsertStatus::CopiedFallback; - } - insertion_success_status() + macos_insert_status_after_paste(simulate_paste()) } /// Copy text without attempting a synthetic paste. Used when the platform cannot @@ -99,6 +95,17 @@ impl TextInserter { } } +#[cfg(target_os = "macos")] +fn macos_insert_status_after_paste(result: Result<(), String>) -> InsertStatus { + match result { + Ok(()) => insertion_success_status(), + Err(err) => { + log::warn!("[insertion] simulated paste failed: {}", err); + InsertStatus::CopiedFallback + } + } +} + impl Default for TextInserter { fn default() -> Self { Self::new() @@ -187,22 +194,29 @@ fn insert_with_clipboard_restore(text: &str, restore_clipboard_after_paste: bool #[cfg(not(target_os = "macos"))] fn schedule_clipboard_restore(plan: ClipboardRestorePlan) { + let (restore_id, original_text) = + remember_pending_clipboard_restore(plan.previous_text.clone()); + std::thread::spawn(move || { + restore_clipboard_after_delay(plan, original_text, restore_id, CLIPBOARD_RESTORE_DELAY) + }); +} + +#[cfg(not(target_os = "macos"))] +fn remember_pending_clipboard_restore(previous_text: Option) -> (u64, Option) { let restore_id = NEXT_CLIPBOARD_RESTORE_ID.fetch_add(1, Ordering::SeqCst); let original_text = { let mut pending = PENDING_CLIPBOARD_RESTORE.lock(); let original = pending .as_ref() .map(|batch| batch.original_text.clone()) - .unwrap_or_else(|| plan.previous_text.clone()); + .unwrap_or(previous_text); *pending = Some(PendingClipboardRestore { latest_restore_id: restore_id, original_text: original.clone(), }); original }; - std::thread::spawn(move || { - restore_clipboard_after_delay(plan, original_text, restore_id, CLIPBOARD_RESTORE_DELAY) - }); + (restore_id, original_text) } #[cfg(not(target_os = "macos"))] @@ -468,6 +482,55 @@ mod tests { assert_eq!(inserter.copy_fallback(""), InsertStatus::CopiedFallback); } + #[test] + #[cfg(not(target_os = "macos"))] + fn pending_clipboard_restore_keeps_first_original_until_latest_restore() { + *PENDING_CLIPBOARD_RESTORE.lock() = None; + + let (first_id, first_original) = + remember_pending_clipboard_restore(Some("user clipboard".to_string())); + let (second_id, second_original) = + remember_pending_clipboard_restore(Some("first dictated text".to_string())); + + assert_ne!(first_id, second_id); + assert_eq!(first_original.as_deref(), Some("user clipboard")); + assert_eq!(second_original.as_deref(), Some("user clipboard")); + assert!(!is_latest_clipboard_restore(first_id)); + assert!(is_latest_clipboard_restore(second_id)); + + clear_pending_clipboard_restore(first_id); + assert!(is_latest_clipboard_restore(second_id)); + clear_pending_clipboard_restore(second_id); + assert!(!is_latest_clipboard_restore(second_id)); + } + + #[test] + #[cfg(not(target_os = "macos"))] + fn clipboard_restore_skips_when_clipboard_no_longer_matches_inserted_text() { + assert!(should_restore_clipboard( + Some("dictated text"), + "dictated text" + )); + assert!(!should_restore_clipboard( + Some("user edited clipboard"), + "dictated text" + )); + assert!(!should_restore_clipboard(None, "dictated text")); + } + + #[test] + #[cfg(target_os = "macos")] + fn macos_paste_failure_keeps_copied_fallback_available() { + assert_eq!( + macos_insert_status_after_paste(Ok(())), + InsertStatus::Inserted + ); + assert_eq!( + macos_insert_status_after_paste(Err("AX write unavailable".to_string())), + InsertStatus::CopiedFallback + ); + } + #[test] #[cfg(target_os = "windows")] fn delayed_terminal_paste_must_see_dictated_text_before_clipboard_restore() { From ffeaa243d97d8206f50c128f9fc2851a8881f480 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Fri, 8 May 2026 19:15:54 +0800 Subject: [PATCH 3/3] Keep backend test CI executable on Windows The #295 unit suite now runs in CI, but the Windows clean runner exits before the lib test harness starts with STATUS_ENTRYPOINT_NOT_FOUND when optional native runtime entrypoints are absent. Windows still needs cfg/link coverage, while macOS and Linux can execute the shared Rust unit suite. Constraint: Preserve the new Rust test gate without making optional Windows native runtime installation a CI prerequisite. Rejected: Install Foundry/Windows App Runtime into every CI run | broader, slower, and unrelated to backend unit-test coverage. Confidence: high Scope-risk: narrow Directive: Do not switch Windows back to executing the lib test binary until the optional native runtime dependency is present on the runner. Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib Not-tested: Windows CI rerun before push. --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0baf87fe..6f7453e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,8 +92,16 @@ jobs: run: cargo check --manifest-path src-tauri/Cargo.toml - name: Run Rust backend unit tests + if: runner.os != 'Windows' run: cargo test --manifest-path src-tauri/Cargo.toml --lib + - name: Compile Rust backend unit tests (Windows) + # Windows runner 能链接 lib test binary,但干净镜像缺少可选 native runtime + # DLL entrypoint 时,进程会在 test harness 启动前退出。这里保留 cfg/link + # 覆盖;共享单测在 macOS / Linux 上实际执行。 + if: runner.os == 'Windows' + run: cargo test --manifest-path src-tauri/Cargo.toml --lib --no-run + - name: Verify version sync across all 5 files # 两个平台都跑这个校验:Windows runner 自带 git-bash,跨 shell 表现一致。 # 一旦版本号 drift 立刻 fail,避免发版时再发现漏改。