Merge pull request #361 from H-Chris233/fix/issue-295-rust-unit-tests
为 Rust 后端补充单元测试门禁
This commit is contained in:
7 files changed
+650
-16
No files matched your search
@@ -91,6 +91,17 @@ jobs:
|
||||
- name: Check Tauri backend (cargo check)
|
||||
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,避免发版时再发现漏改。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<tokio::sync::Mutex<()>> = 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();
|
||||
@@ -4483,6 +4577,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();
|
||||
|
||||
@@ -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,
|
||||
@@ -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 {
|
||||
@@ -550,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<Shared> {
|
||||
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<Shared>) -> (CallbackContext, mpsc::Receiver<HotkeyEvent>) {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
(
|
||||
CallbackContext {
|
||||
shared,
|
||||
tx,
|
||||
tap: std::sync::Mutex::new(None),
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
fn drain(rx: &mpsc::Receiver<HotkeyEvent>) -> Vec<HotkeyEvent> {
|
||||
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 ───────────────────────────
|
||||
@@ -857,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<Shared> {
|
||||
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<Shared>) -> (CallbackContext, mpsc::Receiver<HotkeyEvent>) {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
(
|
||||
CallbackContext {
|
||||
shared,
|
||||
tx,
|
||||
hook: std::sync::Mutex::new(None),
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
fn drain(rx: &mpsc::Receiver<HotkeyEvent>) -> Vec<HotkeyEvent> {
|
||||
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 ───────────────────────────
|
||||
@@ -1072,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<HotkeyEvent>) -> Vec<HotkeyEvent> {
|
||||
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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> (u64, Option<String>) {
|
||||
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"))]
|
||||
@@ -439,8 +453,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 +474,63 @@ 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(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() {
|
||||
|
||||
@@ -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<Vec<Vec<u8>>>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user