From 88e8418982ba39a635fcad8247dff04b0bac55b1 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Fri, 8 May 2026 15:18:40 +0800 Subject: [PATCH] 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); + } +}