From 92849fe185cd32d07fe943728d045b500daae66d Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 11:39:34 +0800 Subject: [PATCH 01/20] build: drop lib target so unit tests compile and run once Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTRIBUTING.md | 1 - Cargo.toml | 4 ---- src/lib.rs | 14 -------------- 3 files changed, 19 deletions(-) delete mode 100644 src/lib.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f4820d..b6c83dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,6 @@ Be respectful, constructive, and collaborative. We're all here to build better t sumvox/ ├── src/ │ ├── main.rs # Entry point and hook orchestration -│ ├── lib.rs # Library surface shared with the integration tests │ ├── cli.rs # CLI argument parsing │ ├── config.rs # Configuration loading, migration and saving │ ├── pipeline.rs # Shared summarize -> speak flow (say / sum / json / hooks) diff --git a/Cargo.toml b/Cargo.toml index 020bf25..1815962 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,10 +44,6 @@ panic = "abort" strip = true opt-level = "z" # Optimize for size -[lib] -name = "sumvox" -path = "src/lib.rs" - [[bin]] name = "sumvox" path = "src/main.rs" diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 4f12131..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -// Library exports for sumvox - -pub mod audio; -pub mod cli; -pub mod config; -pub mod error; -pub mod hooks; -pub mod llm; -pub mod notify_log; -pub mod pipeline; -pub mod provider_factory; -pub mod queue; -pub mod transcript; -pub mod tts; From fcddd3716d507a7c91cc7dc0292843298c01ba04 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 11:40:10 +0800 Subject: [PATCH 02/20] test: gate network and audio tests behind ignore, move hermetic CLI tests to tests/cli.rs Co-Authored-By: Claude Opus 5.5 (1M context) --- src/audio/player.rs | 3 + src/llm/anthropic.rs | 2 +- src/llm/gemini.rs | 2 +- src/llm/ollama.rs | 2 +- src/llm/openai.rs | 2 +- src/tts/macos.rs | 1 + tests/cli.rs | 335 +++++++++++++++++++++++++++++++++++++++++++ tests/e2e.rs | 279 ++++------------------------------- 8 files changed, 368 insertions(+), 258 deletions(-) create mode 100644 tests/cli.rs diff --git a/src/audio/player.rs b/src/audio/player.rs index 1f15b1b..f0881ee 100644 --- a/src/audio/player.rs +++ b/src/audio/player.rs @@ -665,6 +665,7 @@ mod tests { // `afplay` is present, so these exercise the genuine end-to-end path. #[test] #[cfg(target_os = "macos")] + #[ignore = "e2e-audio"] fn test_play_bytes_success() { let wav_data = create_test_wav(); let result = play_bytes(&wav_data, 50, "sumvox_test"); @@ -673,6 +674,7 @@ mod tests { #[test] #[cfg(target_os = "macos")] + #[ignore = "e2e-audio"] fn test_play_bytes_zero_volume() { let wav_data = create_test_wav(); let result = play_bytes(&wav_data, 0, "sumvox_test_zero"); @@ -681,6 +683,7 @@ mod tests { #[test] #[cfg(target_os = "macos")] + #[ignore = "e2e-audio"] fn test_play_bytes_max_volume() { let wav_data = create_test_wav(); let result = play_bytes(&wav_data, 100, "sumvox_test_max"); diff --git a/src/llm/anthropic.rs b/src/llm/anthropic.rs index 11f5e07..8309065 100644 --- a/src/llm/anthropic.rs +++ b/src/llm/anthropic.rs @@ -323,7 +323,7 @@ mod tests { // Integration test - requires actual API key #[tokio::test] - #[ignore] + #[ignore = "e2e-network"] async fn test_generate_with_real_api() { let api_key = std::env::var("ANTHROPIC_API_KEY").expect("ANTHROPIC_API_KEY not set"); let provider = AnthropicProvider::with_base_url( diff --git a/src/llm/gemini.rs b/src/llm/gemini.rs index 42fe8b6..a0896cd 100644 --- a/src/llm/gemini.rs +++ b/src/llm/gemini.rs @@ -370,7 +370,7 @@ mod tests { // Integration test - requires actual API key #[tokio::test] - #[ignore] + #[ignore = "e2e-network"] async fn test_generate_with_real_api() { let api_key = std::env::var("GEMINI_API_KEY").expect("GEMINI_API_KEY not set"); let provider = GeminiProvider::with_base_url( diff --git a/src/llm/ollama.rs b/src/llm/ollama.rs index 94cde64..3d2160e 100644 --- a/src/llm/ollama.rs +++ b/src/llm/ollama.rs @@ -264,7 +264,7 @@ mod tests { // Integration test - requires actual Ollama service running #[tokio::test] - #[ignore] + #[ignore = "e2e-network"] async fn test_generate_with_real_ollama() { let provider = OllamaProvider::with_base_url( "http://localhost:11434".to_string(), diff --git a/src/llm/openai.rs b/src/llm/openai.rs index 89996a2..33e11c0 100644 --- a/src/llm/openai.rs +++ b/src/llm/openai.rs @@ -425,7 +425,7 @@ mod tests { // Integration test - requires actual API key #[tokio::test] - #[ignore] + #[ignore = "e2e-network"] async fn test_generate_with_real_api() { let api_key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY not set"); let provider = OpenAIProvider::with_base_url( diff --git a/src/tts/macos.rs b/src/tts/macos.rs index 3d15b36..7999cee 100644 --- a/src/tts/macos.rs +++ b/src/tts/macos.rs @@ -155,6 +155,7 @@ mod tests { // if `say -o` or the playback handoff breaks. Volume 1 stays near-silent. #[cfg(target_os = "macos")] #[tokio::test] + #[ignore = "e2e-audio"] async fn test_speak_renders_and_plays() { let provider = MacOsTtsProvider::new(None, 300, 1); let result = provider.speak("test").await.unwrap(); diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..ce2ebbf --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,335 @@ +// Hermetic CLI tests for the sumvox binary: no network, no audio, no real config. +// Each test runs with HOME pointed at a temp dir so ~/.config/sumvox is never touched. + +use assert_cmd::cargo::cargo_bin_cmd; +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use std::path::Path; +use tempfile::TempDir; + +/// The local TTS engine for the platform under test (configs below name `macos`). +const LOCAL_TTS: &str = if cfg!(target_os = "macos") { + "macos" +} else { + "espeak" +}; + +struct TestEnv { + home_dir: TempDir, +} + +impl TestEnv { + fn new() -> Self { + let home_dir = TempDir::new().expect("Failed to create temp dir"); + Self { home_dir } + } + + fn setup_with_config(&self, toml_content: &str) -> &Path { + let config_dir = self.home_dir.path().join(".config/sumvox"); + fs::create_dir_all(&config_dir).unwrap(); + fs::write( + config_dir.join("config.toml"), + toml_content.replace("\"macos\"", &format!("\"{LOCAL_TTS}\"")), + ) + .unwrap(); + self.home_dir.path() + } + + fn cmd(&self) -> Command { + let mut cmd = cargo_bin_cmd!("sumvox"); + cmd.env("HOME", self.home_dir.path()); + cmd.env_remove("SUMVOX_DISABLE"); + cmd + } + + fn cmd_debug(&self) -> Command { + let mut cmd = self.cmd(); + cmd.env("RUST_LOG", "debug"); + cmd + } + + fn home_path(&self) -> &Path { + self.home_dir.path() + } +} + +fn notification_json(message: &str, notification_type: &str) -> String { + serde_json::json!({ + "session_id": "e2e-test", + "transcript_path": "/tmp/fake-transcript.jsonl", + "hook_event_name": "Notification", + "message": message, + "notification_type": notification_type + }) + .to_string() +} + +fn config_without_llm() -> String { + r#"[llm] +providers = [] +[llm.parameters] +max_tokens = 100 +temperature = 0.3 + +[tts] +[[tts.providers]] +name = "macos" +rate = 200 + +[summarization] +turns = 1 +system_message = "Test" +prompt_template = "Summarize: {context}" +fallback_message = "Test completed" + +[hooks.claude_code] +notification_filter = ["*"] +notification_tts_provider = "macos" +stop_tts_provider = "macos" +"# + .to_string() +} + +fn config_no_tts() -> String { + r#"[llm] +providers = [] +[llm.parameters] +max_tokens = 100 +temperature = 0.3 + +[tts] +providers = [] + +[summarization] +turns = 1 +system_message = "Test" +prompt_template = "Summarize: {context}" +fallback_message = "Test completed" + +[hooks.claude_code] +notification_filter = ["*"] +"# + .to_string() +} + +fn config_with_audio_file(path: &str) -> String { + format!( + r#"[llm] +providers = [] +[llm.parameters] +max_tokens = 100 +temperature = 0.3 + +[tts] +[[tts.providers]] +name = "audio_file" +path = "{path}" + +[[tts.providers]] +name = "macos" +rate = 200 + +[summarization] +turns = 1 +system_message = "Test" +prompt_template = "Summarize: {{context}}" +fallback_message = "Test completed" + +[hooks.claude_code] +notification_filter = ["*"] +notification_tts_provider = "macos" +stop_tts_provider = "macos" +"# + ) +} + +fn config_with_specific_filter(types: &[&str]) -> String { + let filter = types + .iter() + .map(|t| format!("\"{}\"", t)) + .collect::>() + .join(", "); + format!( + r#"[llm] +providers = [] +[llm.parameters] +max_tokens = 100 +temperature = 0.3 + +[tts] +[[tts.providers]] +name = "macos" +rate = 200 + +[summarization] +turns = 1 +system_message = "Test" +prompt_template = "Summarize: {{context}}" +fallback_message = "Test completed" + +[hooks.claude_code] +notification_filter = [{filter}] +notification_tts_provider = "macos" +stop_tts_provider = "macos" +"# + ) +} + +#[test] +fn test_version() { + let env = TestEnv::new(); + env.cmd() + .arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("sumvox")); +} + +#[test] +fn test_help() { + let env = TestEnv::new(); + env.cmd() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("say")) + .stdout(predicate::str::contains("sum")) + .stdout(predicate::str::contains("json")) + .stdout(predicate::str::contains("init")); +} + +#[test] +fn test_empty_stdin() { + let env = TestEnv::new(); + + env.cmd() + .arg("json") + .write_stdin("") + .assert() + .failure() + .stderr(predicate::str::contains("Empty JSON input")); +} + +#[test] +fn test_init_creates_config() { + let env = TestEnv::new(); + + env.cmd().arg("init").assert().success(); + + let config_path = env.home_path().join(".config/sumvox/config.toml"); + assert!( + config_path.exists(), + "config.toml should be created by init" + ); +} + +#[test] +fn test_init_force() { + let env = TestEnv::new(); + + // Create a legacy config.yaml to trigger "already exists" check + let config_dir = env.home_path().join(".config/sumvox"); + fs::create_dir_all(&config_dir).unwrap(); + fs::write(config_dir.join("config.yaml"), "version: '1.0.0'").unwrap(); + + // Without --force: should report existing config + env.cmd() + .arg("init") + .assert() + .success() + .stderr(predicate::str::contains("already exists")); + + // With --force: should overwrite and create config.toml + env.cmd().args(["init", "--force"]).assert().success(); + + let config_path = config_dir.join("config.toml"); + assert!( + config_path.exists(), + "config.toml should be created after init --force" + ); +} + +#[test] +fn test_init_does_not_overwrite_existing_toml() { + let env = TestEnv::new(); + let home = env.setup_with_config("[[tts.providers]]\nname = \"macos\"\n"); + let config_path = home.join(".config/sumvox/config.toml"); + let before = fs::read_to_string(&config_path).unwrap(); + + // Without --force: the existing config.toml must survive untouched + env.cmd() + .arg("init") + .assert() + .success() + .stderr(predicate::str::contains("already exists")); + + assert_eq!( + fs::read_to_string(&config_path).unwrap(), + before, + "init without --force must not overwrite an existing config.toml" + ); + + // With --force: overwriting is the point + env.cmd().args(["init", "--force"]).assert().success(); + assert_ne!( + fs::read_to_string(&config_path).unwrap(), + before, + "init --force should replace the existing config.toml" + ); +} + +#[test] +fn test_sum_no_llm_config() { + let env = TestEnv::new(); + env.setup_with_config(&config_without_llm()); + + // With no LLM providers, summary is empty → warning printed, exit 0 + env.cmd() + .args(["sum", "Hello world", "--no-speak"]) + .assert() + .success() + .stderr(predicate::str::contains("Empty summary generated")); +} + +#[test] +fn test_say_unknown_tts() { + let env = TestEnv::new(); + // Use config with no TTS providers — "nonexistent" falls back to Auto, + // Auto with empty providers → error + env.setup_with_config(&config_no_tts()); + + env.cmd() + .args(["say", "hello", "--tts", "nonexistent"]) + .assert() + .failure() + .stderr(predicate::str::contains("No TTS provider")); +} + +#[test] +fn test_say_audio_missing_path() { + let env = TestEnv::new(); + env.setup_with_config(&config_with_audio_file("/nonexistent/path/sound.wav")); + + env.cmd() + .args(["say", "ignored text", "--tts", "audio_file"]) + .assert() + .failure() + .stderr(predicate::str::contains("does not exist")); +} + +#[test] +fn test_notification_filtered() { + let env = TestEnv::new(); + // Config only allows "permission_prompt" — send "auth_success" which is not in filter + env.setup_with_config(&config_with_specific_filter(&["permission_prompt"])); + + let json = notification_json("Should be filtered", "auth_success"); + + env.cmd_debug() + .arg("json") + .write_stdin(json) + .assert() + .success() + .stdout(predicate::str::contains("not in filter")); +} diff --git a/tests/e2e.rs b/tests/e2e.rs index 5739e81..515e50d 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -6,8 +6,9 @@ // Every config installed by the harness is written with `macos`; on non-macOS the // harness rewrites that literal to `espeak` so one config serves both platforms. // -// Run: cargo test --test e2e -// Debug single test: cargo test --test e2e test_name -- --nocapture +// Every test is #[ignore]d so plain `cargo test` stays offline and silent. +// Run: cargo test --test e2e -- --ignored +// Debug single test: cargo test --test e2e test_name -- --ignored --nocapture use assert_cmd::cargo::cargo_bin_cmd; use assert_cmd::Command; @@ -137,54 +138,6 @@ fn notification_json_stop_active() -> String { .to_string() } -fn config_without_llm() -> String { - r#"[llm] -providers = [] -[llm.parameters] -max_tokens = 100 -temperature = 0.3 - -[tts] -[[tts.providers]] -name = "macos" -rate = 200 - -[summarization] -turns = 1 -system_message = "Test" -prompt_template = "Summarize: {context}" -fallback_message = "Test completed" - -[hooks.claude_code] -notification_filter = ["*"] -notification_tts_provider = "macos" -stop_tts_provider = "macos" -"# - .to_string() -} - -fn config_no_tts() -> String { - r#"[llm] -providers = [] -[llm.parameters] -max_tokens = 100 -temperature = 0.3 - -[tts] -providers = [] - -[summarization] -turns = 1 -system_message = "Test" -prompt_template = "Summarize: {context}" -fallback_message = "Test completed" - -[hooks.claude_code] -notification_filter = ["*"] -"# - .to_string() -} - fn config_with_audio_file(path: &str) -> String { format!( r#"[llm] @@ -244,154 +197,12 @@ stop_tts_provider = "macos" ) } -fn config_with_specific_filter(types: &[&str]) -> String { - let filter = types - .iter() - .map(|t| format!("\"{}\"", t)) - .collect::>() - .join(", "); - format!( - r#"[llm] -providers = [] -[llm.parameters] -max_tokens = 100 -temperature = 0.3 - -[tts] -[[tts.providers]] -name = "macos" -rate = 200 - -[summarization] -turns = 1 -system_message = "Test" -prompt_template = "Summarize: {{context}}" -fallback_message = "Test completed" - -[hooks.claude_code] -notification_filter = [{filter}] -notification_tts_provider = "macos" -stop_tts_provider = "macos" -"# - ) -} - // ============================================================================ -// CLI Basic Behavior (3) -// ============================================================================ - -#[test] -fn test_version() { - let mut cmd = cargo_bin_cmd!("sumvox"); - cmd.env_remove("SUMVOX_DISABLE"); - cmd.arg("--version") - .assert() - .success() - .stdout(predicate::str::contains("sumvox")); -} - -#[test] -fn test_help() { - let mut cmd = cargo_bin_cmd!("sumvox"); - cmd.env_remove("SUMVOX_DISABLE"); - cmd.arg("--help") - .assert() - .success() - .stdout(predicate::str::contains("say")) - .stdout(predicate::str::contains("sum")) - .stdout(predicate::str::contains("json")) - .stdout(predicate::str::contains("init")); -} - -#[test] -fn test_empty_stdin() { - let env = TestEnv::new(); - - env.cmd() - .arg("json") - .write_stdin("") - .assert() - .failure() - .stderr(predicate::str::contains("Empty JSON input")); -} - -// ============================================================================ -// Init Command (3) -// ============================================================================ - -#[test] -fn test_init_creates_config() { - let env = TestEnv::new(); - - env.cmd().arg("init").assert().success(); - - let config_path = env.home_path().join(".config/sumvox/config.toml"); - assert!( - config_path.exists(), - "config.toml should be created by init" - ); -} - -#[test] -fn test_init_force() { - let env = TestEnv::new(); - - // Create a legacy config.yaml to trigger "already exists" check - let config_dir = env.home_path().join(".config/sumvox"); - fs::create_dir_all(&config_dir).unwrap(); - fs::write(config_dir.join("config.yaml"), "version: '1.0.0'").unwrap(); - - // Without --force: should report existing config - env.cmd() - .arg("init") - .assert() - .success() - .stderr(predicate::str::contains("already exists")); - - // With --force: should overwrite and create config.toml - env.cmd().args(["init", "--force"]).assert().success(); - - let config_path = config_dir.join("config.toml"); - assert!( - config_path.exists(), - "config.toml should be created after init --force" - ); -} - -#[test] -fn test_init_does_not_overwrite_existing_toml() { - let env = TestEnv::new(); - let home = env.setup_with_config("[[tts.providers]]\nname = \"macos\"\n"); - let config_path = home.join(".config/sumvox/config.toml"); - let before = fs::read_to_string(&config_path).unwrap(); - - // Without --force: the existing config.toml must survive untouched - env.cmd() - .arg("init") - .assert() - .success() - .stderr(predicate::str::contains("already exists")); - - assert_eq!( - fs::read_to_string(&config_path).unwrap(), - before, - "init without --force must not overwrite an existing config.toml" - ); - - // With --force: overwriting is the point - env.cmd().args(["init", "--force"]).assert().success(); - assert_ne!( - fs::read_to_string(&config_path).unwrap(), - before, - "init --force should replace the existing config.toml" - ); -} - -// ============================================================================ -// LLM — sum Command (4) +// LLM — sum Command // ============================================================================ #[test] +#[ignore = "e2e-network"] fn test_sum_no_speak() { let env = TestEnv::new(); env.setup_base_config(); @@ -409,6 +220,7 @@ fn test_sum_no_speak() { } #[test] +#[ignore = "e2e-network"] fn test_sum_empty_text() { let env = TestEnv::new(); env.setup_base_config(); @@ -421,6 +233,7 @@ fn test_sum_empty_text() { } #[test] +#[ignore = "e2e-network"] fn test_sum_stdin() { let env = TestEnv::new(); env.setup_base_config(); @@ -434,24 +247,12 @@ fn test_sum_stdin() { .stdout(predicate::str::is_empty().not()); } -#[test] -fn test_sum_no_llm_config() { - let env = TestEnv::new(); - env.setup_with_config(&config_without_llm()); - - // With no LLM providers, summary is empty → warning printed, exit 0 - env.cmd() - .args(["sum", "Hello world", "--no-speak"]) - .assert() - .success() - .stderr(predicate::str::contains("Empty summary generated")); -} - // ============================================================================ -// TTS — say Command (4) +// TTS — say Command // ============================================================================ #[test] +#[ignore = "e2e-audio"] fn test_say_local() { let env = TestEnv::new(); env.setup_base_config(); @@ -463,6 +264,7 @@ fn test_say_local() { } #[test] +#[ignore = "e2e-audio"] fn test_say_volume() { let env = TestEnv::new(); env.setup_base_config(); @@ -474,20 +276,7 @@ fn test_say_volume() { } #[test] -fn test_say_unknown_tts() { - let env = TestEnv::new(); - // Use config with no TTS providers — "nonexistent" falls back to Auto, - // Auto with empty providers → error - env.setup_with_config(&config_no_tts()); - - env.cmd() - .args(["say", "hello", "--tts", "nonexistent"]) - .assert() - .failure() - .stderr(predicate::str::contains("No TTS provider")); -} - -#[test] +#[ignore = "e2e-network"] fn test_say_google_tts() { let env = TestEnv::new(); env.setup_base_config(); @@ -500,10 +289,11 @@ fn test_say_google_tts() { } // ============================================================================ -// Audio File Provider (4) +// Audio File Provider // ============================================================================ #[test] +#[ignore = "e2e-audio"] fn test_say_audio_single_file() { let env = TestEnv::new(); @@ -521,6 +311,7 @@ fn test_say_audio_single_file() { } #[test] +#[ignore = "e2e-audio"] fn test_say_audio_directory() { let env = TestEnv::new(); @@ -539,18 +330,7 @@ fn test_say_audio_directory() { } #[test] -fn test_say_audio_missing_path() { - let env = TestEnv::new(); - env.setup_with_config(&config_with_audio_file("/nonexistent/path/sound.wav")); - - env.cmd() - .args(["say", "ignored text", "--tts", "audio_file"]) - .assert() - .failure() - .stderr(predicate::str::contains("does not exist")); -} - -#[test] +#[ignore = "e2e-network"] fn test_say_audio_no_config() { let env = TestEnv::new(); env.setup_base_config(); // base config has no audio_file provider @@ -563,10 +343,11 @@ fn test_say_audio_no_config() { } // ============================================================================ -// LLM + TTS Full Flow (2) +// LLM + TTS Full Flow // ============================================================================ #[test] +#[ignore = "e2e-audio"] fn test_sum_full_flow_local() { let env = TestEnv::new(); env.setup_base_config(); @@ -585,6 +366,7 @@ fn test_sum_full_flow_local() { } #[test] +#[ignore = "e2e-network"] fn test_sum_full_flow_google_tts() { let env = TestEnv::new(); env.setup_base_config(); @@ -598,10 +380,11 @@ fn test_sum_full_flow_google_tts() { } // ============================================================================ -// Hook Dispatch (3) +// Hook Dispatch // ============================================================================ #[test] +#[ignore = "e2e-audio"] fn test_notification_hook() { let env = TestEnv::new(); env.setup_base_config(); @@ -618,22 +401,7 @@ fn test_notification_hook() { } #[test] -fn test_notification_filtered() { - let env = TestEnv::new(); - // Config only allows "permission_prompt" — send "auth_success" which is not in filter - env.setup_with_config(&config_with_specific_filter(&["permission_prompt"])); - - let json = notification_json("Should be filtered", "auth_success"); - - env.cmd_debug() - .arg("json") - .write_stdin(json) - .assert() - .success() - .stdout(predicate::str::contains("not in filter")); -} - -#[test] +#[ignore = "e2e-network"] fn test_stop_hook_active() { let env = TestEnv::new(); env.setup_base_config(); @@ -649,10 +417,11 @@ fn test_stop_hook_active() { } // ============================================================================ -// Notification Queue (3) +// Notification Queue // ============================================================================ #[test] +#[ignore = "e2e-audio"] fn test_queue_lock_acquired() { let env = TestEnv::new(); env.setup_base_config(); @@ -669,6 +438,7 @@ fn test_queue_lock_acquired() { } #[test] +#[ignore = "e2e-audio"] fn test_queue_disabled() { let env = TestEnv::new(); env.setup_with_config(&config_with_queue(0)); @@ -685,6 +455,7 @@ fn test_queue_disabled() { } #[test] +#[ignore = "e2e-audio"] fn test_queue_concurrent() { use std::process::Stdio; From d03f475a713fb081c900ad5f6e58d9b8a7f10462 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 11:40:18 +0800 Subject: [PATCH 03/20] ci: run default and e2e test tiers separately Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 7 ++----- justfile | 6 +++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc24d44..f0afac8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,10 +76,7 @@ jobs: key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - name: Run tests - run: cargo test --lib --bins --verbose - - - name: Run tests with all features - run: cargo test --lib --bins --all-features --verbose + run: cargo test --verbose e2e: name: E2E @@ -118,7 +115,7 @@ jobs: E2E_CONFIG: ${{ secrets.E2E_CONFIG }} SDL_AUDIODRIVER: dummy if: env.E2E_CONFIG != '' - run: cargo test --test e2e --verbose + run: cargo test --test e2e --verbose -- --ignored timeout-minutes: 10 build: diff --git a/justfile b/justfile index 411a129..5267cdc 100644 --- a/justfile +++ b/justfile @@ -5,10 +5,14 @@ default: @just --list -# Run all tests +# Run unit and component tests (offline, silent) test: cargo test +# Run e2e tests (needs config/e2e_test.toml, hits real APIs, plays audio) +test-e2e: + cargo test --test e2e -- --ignored + # Run tests with output test-verbose: cargo test -- --nocapture From 0399b39b8b0ffae5a88f6c20ac6838b72ee257cd Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 11:40:24 +0800 Subject: [PATCH 04/20] test: clear every API key env var in tts fallback test Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tts/mod.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/tts/mod.rs b/src/tts/mod.rs index aea002f..e4f25b2 100644 --- a/src/tts/mod.rs +++ b/src/tts/mod.rs @@ -858,9 +858,16 @@ mod tests { }, ]; - // Clear env vars to ensure fallback - std::env::remove_var("GOOGLE_CLOUD_PROJECT"); - std::env::remove_var("GCP_PROJECT"); + // Every env var the google provider's key lookup reads. Only removed, never + // set, anywhere in the suite, so concurrent tests cannot observe a flip. + for var in [ + "GOOGLE_CLOUD_PROJECT", + "GCP_PROJECT", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + ] { + std::env::remove_var(var); + } let result = create_tts_from_config(&providers); assert!(result.is_ok()); From 6a9a5b9c52b1184518476c154c98ee6dd2ae4f2c Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 11:42:38 +0800 Subject: [PATCH 05/20] test: move offline e2e tests into hermetic cli tier Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/cli.rs | 193 ++++++++++++++++++++++++++++++++++++++++ tests/e2e.rs | 245 --------------------------------------------------- 2 files changed, 193 insertions(+), 245 deletions(-) diff --git a/tests/cli.rs b/tests/cli.rs index ce2ebbf..09fedfb 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -36,6 +36,13 @@ impl TestEnv { self.home_dir.path() } + /// Create the mute flag so playback short-circuits before any audio device is touched. + fn mute(&self) { + let config_dir = self.home_dir.path().join(".config/sumvox"); + fs::create_dir_all(&config_dir).unwrap(); + fs::write(config_dir.join("muted"), "").unwrap(); + } + fn cmd(&self) -> Command { let mut cmd = cargo_bin_cmd!("sumvox"); cmd.env("HOME", self.home_dir.path()); @@ -91,6 +98,46 @@ stop_tts_provider = "macos" .to_string() } +fn config_with_queue(timeout: u64) -> String { + format!( + r#"[llm] +providers = [] +[llm.parameters] +max_tokens = 100 +temperature = 0.3 + +[tts] +[[tts.providers]] +name = "macos" +rate = 200 + +[summarization] +turns = 1 +system_message = "Test" +prompt_template = "Summarize: {{context}}" +fallback_message = "Test completed" + +[hooks.claude_code] +notification_filter = ["*"] +queue_timeout = {timeout} +notification_tts_provider = "macos" +stop_tts_provider = "macos" +"# + ) +} + +fn notification_json_stop_active() -> String { + serde_json::json!({ + "session_id": "e2e-test", + "transcript_path": "/tmp/fake-transcript.jsonl", + "hook_event_name": "Notification", + "stop_hook_active": true, + "message": "Should be ignored", + "notification_type": "permission_prompt" + }) + .to_string() +} + fn config_no_tts() -> String { r#"[llm] providers = [] @@ -333,3 +380,149 @@ fn test_notification_filtered() { .success() .stdout(predicate::str::contains("not in filter")); } + +#[test] +fn test_sum_empty_text() { + let env = TestEnv::new(); + env.setup_with_config(&config_without_llm()); + + env.cmd() + .args(["sum", ""]) + .assert() + .failure() + .stderr(predicate::str::contains("Empty text provided")); +} + +#[test] +fn test_say_local() { + let env = TestEnv::new(); + env.setup_with_config(&config_without_llm()); + env.mute(); + + env.cmd() + .args(["say", "hello", "--tts", LOCAL_TTS]) + .assert() + .success(); +} + +#[test] +fn test_say_volume() { + let env = TestEnv::new(); + env.setup_with_config(&config_without_llm()); + env.mute(); + + env.cmd() + .args(["say", "hello", "--tts", LOCAL_TTS, "--volume", "50"]) + .assert() + .success(); +} + +#[test] +fn test_say_audio_no_config() { + let env = TestEnv::new(); + env.setup_with_config(&config_without_llm()); + + env.cmd() + .args(["say", "hello", "--tts", "audio_file"]) + .assert() + .failure() + .stderr(predicate::str::contains("audio_file provider not found")); +} + +#[test] +fn test_notification_hook() { + let env = TestEnv::new(); + env.setup_with_config(&config_without_llm()); + env.mute(); + + env.cmd_debug() + .arg("json") + .write_stdin(notification_json("Test notification", "permission_prompt")) + .assert() + .success() + .stdout(predicate::str::contains("Speaking notification")); +} + +#[test] +fn test_stop_hook_active() { + let env = TestEnv::new(); + env.setup_with_config(&config_without_llm()); + + env.cmd_debug() + .arg("json") + .write_stdin(notification_json_stop_active()) + .assert() + .success() + .stdout(predicate::str::contains("preventing infinite loop")); +} + +#[test] +fn test_queue_lock_acquired() { + let env = TestEnv::new(); + env.setup_with_config(&config_without_llm()); + env.mute(); + + env.cmd_debug() + .arg("json") + .write_stdin(notification_json("Queue test", "permission_prompt")) + .assert() + .success() + .stdout(predicate::str::contains("Queue lock acquired")); +} + +#[test] +fn test_queue_disabled() { + let env = TestEnv::new(); + env.setup_with_config(&config_with_queue(0)); + env.mute(); + + env.cmd_debug() + .arg("json") + .write_stdin(notification_json( + "Queue disabled test", + "permission_prompt", + )) + .assert() + .success() + .stdout(predicate::str::contains("queue disabled")); +} + +#[test] +fn test_queue_concurrent() { + use std::io::Write; + use std::process::Stdio; + + let env = TestEnv::new(); + env.setup_with_config(&config_without_llm()); + env.mute(); + + let bin = assert_cmd::cargo::cargo_bin!("sumvox"); + let spawn = |message: &str| { + let mut child = std::process::Command::new(bin) + .arg("json") + .env("HOME", env.home_path()) + .env_remove("SUMVOX_DISABLE") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Failed to spawn child"); + child + .stdin + .take() + .unwrap() + .write_all(notification_json(message, "permission_prompt").as_bytes()) + .unwrap(); + child + }; + + let (a, b) = (spawn("Concurrent A"), spawn("Concurrent B")); + for child in [a, b] { + let output = child.wait_with_output().expect("Failed to wait for child"); + assert!( + output.status.success(), + "child failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/tests/e2e.rs b/tests/e2e.rs index 515e50d..8fdf558 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -115,29 +115,6 @@ fn create_minimal_wav(path: &Path) { fs::write(path, &wav).unwrap(); } -fn notification_json(message: &str, notification_type: &str) -> String { - serde_json::json!({ - "session_id": "e2e-test", - "transcript_path": "/tmp/fake-transcript.jsonl", - "hook_event_name": "Notification", - "message": message, - "notification_type": notification_type - }) - .to_string() -} - -fn notification_json_stop_active() -> String { - serde_json::json!({ - "session_id": "e2e-test", - "transcript_path": "/tmp/fake-transcript.jsonl", - "hook_event_name": "Notification", - "stop_hook_active": true, - "message": "Should be ignored", - "notification_type": "permission_prompt" - }) - .to_string() -} - fn config_with_audio_file(path: &str) -> String { format!( r#"[llm] @@ -169,34 +146,6 @@ stop_tts_provider = "macos" ) } -fn config_with_queue(timeout: u64) -> String { - format!( - r#"[llm] -providers = [] -[llm.parameters] -max_tokens = 100 -temperature = 0.3 - -[tts] -[[tts.providers]] -name = "macos" -rate = 200 - -[summarization] -turns = 1 -system_message = "Test" -prompt_template = "Summarize: {{context}}" -fallback_message = "Test completed" - -[hooks.claude_code] -notification_filter = ["*"] -queue_timeout = {timeout} -notification_tts_provider = "macos" -stop_tts_provider = "macos" -"# - ) -} - // ============================================================================ // LLM — sum Command // ============================================================================ @@ -219,19 +168,6 @@ fn test_sum_no_speak() { .stdout(predicate::str::is_empty().not()); } -#[test] -#[ignore = "e2e-network"] -fn test_sum_empty_text() { - let env = TestEnv::new(); - env.setup_base_config(); - - env.cmd() - .args(["sum", ""]) - .assert() - .failure() - .stderr(predicate::str::contains("Empty text provided")); -} - #[test] #[ignore = "e2e-network"] fn test_sum_stdin() { @@ -251,30 +187,6 @@ fn test_sum_stdin() { // TTS — say Command // ============================================================================ -#[test] -#[ignore = "e2e-audio"] -fn test_say_local() { - let env = TestEnv::new(); - env.setup_base_config(); - - env.cmd() - .args(["say", "hello", "--tts", LOCAL_TTS]) - .assert() - .success(); -} - -#[test] -#[ignore = "e2e-audio"] -fn test_say_volume() { - let env = TestEnv::new(); - env.setup_base_config(); - - env.cmd() - .args(["say", "hello", "--tts", LOCAL_TTS, "--volume", "50"]) - .assert() - .success(); -} - #[test] #[ignore = "e2e-network"] fn test_say_google_tts() { @@ -329,19 +241,6 @@ fn test_say_audio_directory() { .success(); } -#[test] -#[ignore = "e2e-network"] -fn test_say_audio_no_config() { - let env = TestEnv::new(); - env.setup_base_config(); // base config has no audio_file provider - - env.cmd() - .args(["say", "hello", "--tts", "audio_file"]) - .assert() - .failure() - .stderr(predicate::str::contains("audio_file provider not found")); -} - // ============================================================================ // LLM + TTS Full Flow // ============================================================================ @@ -378,147 +277,3 @@ fn test_sum_full_flow_google_tts() { .success() .stdout(predicate::str::is_empty().not()); } - -// ============================================================================ -// Hook Dispatch -// ============================================================================ - -#[test] -#[ignore = "e2e-audio"] -fn test_notification_hook() { - let env = TestEnv::new(); - env.setup_base_config(); - - let json = notification_json("Test notification", "permission_prompt"); - - env.cmd_debug() - .arg("json") - .write_stdin(json) - .timeout(std::time::Duration::from_secs(15)) - .assert() - .success() - .stdout(predicate::str::contains("Speaking notification")); -} - -#[test] -#[ignore = "e2e-network"] -fn test_stop_hook_active() { - let env = TestEnv::new(); - env.setup_base_config(); - - let json = notification_json_stop_active(); - - env.cmd_debug() - .arg("json") - .write_stdin(json) - .assert() - .success() - .stdout(predicate::str::contains("preventing infinite loop")); -} - -// ============================================================================ -// Notification Queue -// ============================================================================ - -#[test] -#[ignore = "e2e-audio"] -fn test_queue_lock_acquired() { - let env = TestEnv::new(); - env.setup_base_config(); - - let json = notification_json("Queue test", "permission_prompt"); - - env.cmd_debug() - .arg("json") - .write_stdin(json) - .timeout(std::time::Duration::from_secs(15)) - .assert() - .success() - .stdout(predicate::str::contains("Queue lock acquired")); -} - -#[test] -#[ignore = "e2e-audio"] -fn test_queue_disabled() { - let env = TestEnv::new(); - env.setup_with_config(&config_with_queue(0)); - - let json = notification_json("Queue disabled test", "permission_prompt"); - - env.cmd_debug() - .arg("json") - .write_stdin(json) - .timeout(std::time::Duration::from_secs(15)) - .assert() - .success() - .stdout(predicate::str::contains("queue disabled")); -} - -#[test] -#[ignore = "e2e-audio"] -fn test_queue_concurrent() { - use std::process::Stdio; - - let env = TestEnv::new(); - env.setup_base_config(); - - let json1 = notification_json("Concurrent A", "permission_prompt"); - let json2 = notification_json("Concurrent B", "permission_prompt"); - let home = env.home_path().to_path_buf(); - let bin = assert_cmd::cargo::cargo_bin!("sumvox"); - - // Spawn two child processes sharing the same HOME (same queue lock file) - let mut child1 = std::process::Command::new(bin) - .arg("json") - .env("HOME", &home) - .env("RUST_LOG", "debug") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("Failed to spawn child 1"); - - let mut child2 = std::process::Command::new(bin) - .arg("json") - .env("HOME", &home) - .env("RUST_LOG", "debug") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("Failed to spawn child 2"); - - // Write JSON to both processes' stdin - use std::io::Write; - child1 - .stdin - .take() - .unwrap() - .write_all(json1.as_bytes()) - .unwrap(); - child2 - .stdin - .take() - .unwrap() - .write_all(json2.as_bytes()) - .unwrap(); - - // Wait for both to complete - let output1 = child1 - .wait_with_output() - .expect("Failed to wait for child 1"); - let output2 = child2 - .wait_with_output() - .expect("Failed to wait for child 2"); - - assert!( - output1.status.success(), - "Child 1 failed: {}", - String::from_utf8_lossy(&output1.stderr) - ); - assert!( - output2.status.success(), - "Child 2 failed: {}", - String::from_utf8_lossy(&output2.stderr) - ); -} From 8cc9058d6160f8f02248a033eaf917b0267a6880 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 11:46:05 +0800 Subject: [PATCH 06/20] test: remove tests that assert copied logic Tests that replicated production branches inline now either go away or build the wire struct directly and assert its serialized shape. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/hooks/claude_code.rs | 180 -------------------------------------- src/llm/anthropic.rs | 60 ------------- src/llm/gemini.rs | 45 ++-------- src/llm/ollama.rs | 64 +++----------- src/llm/openai.rs | 100 +++++---------------- src/notify_log.rs | 11 --- src/tts/cloud_tts_auth.rs | 16 ++-- src/tts/openai.rs | 16 ---- 8 files changed, 54 insertions(+), 438 deletions(-) diff --git a/src/hooks/claude_code.rs b/src/hooks/claude_code.rs index 9ef7b44..5ea2277 100644 --- a/src/hooks/claude_code.rs +++ b/src/hooks/claude_code.rs @@ -301,7 +301,6 @@ async fn handle_stop( #[cfg(test)] mod tests { use super::*; - use crate::tts::TtsEngine; #[test] fn test_claude_code_input_deserialization() { @@ -357,185 +356,6 @@ mod tests { // ── Volume override tests ────────────────────────────────────────── - #[test] - fn test_stop_volume_default_when_config_unset() { - let config = SumvoxConfig::default(); - let tts_opts = TtsOptions::default(); - - let mut stop_tts_opts = tts_opts.clone(); - if stop_tts_opts.volume.is_none() { - stop_tts_opts.volume = Some(config.hooks.claude_code.stop_volume.unwrap_or(100)); - } - - assert_eq!(stop_tts_opts.volume, Some(100)); - } - - #[test] - fn test_stop_volume_from_hook_config() { - let mut config = SumvoxConfig::default(); - config.hooks.claude_code.stop_volume = Some(80); - let tts_opts = TtsOptions::default(); - - let mut stop_tts_opts = tts_opts.clone(); - if stop_tts_opts.volume.is_none() { - stop_tts_opts.volume = Some(config.hooks.claude_code.stop_volume.unwrap_or(100)); - } - - assert_eq!(stop_tts_opts.volume, Some(80)); - } - - #[test] - fn test_notification_volume_from_hook_config() { - let mut config = SumvoxConfig::default(); - config.hooks.claude_code.notification_volume = Some(60); - let tts_opts = TtsOptions::default(); - - let mut notification_tts_opts = tts_opts.clone(); - if notification_tts_opts.volume.is_none() { - notification_tts_opts.volume = - Some(config.hooks.claude_code.notification_volume.unwrap_or(80)); - } - - assert_eq!(notification_tts_opts.volume, Some(60)); - } - - #[test] - fn test_cli_volume_overrides_hook_config() { - let mut config = SumvoxConfig::default(); - config.hooks.claude_code.stop_volume = Some(80); - - let tts_opts = TtsOptions { - volume: Some(50), // CLI override - ..Default::default() - }; - - let mut stop_tts_opts = tts_opts.clone(); - if stop_tts_opts.volume.is_none() { - stop_tts_opts.volume = Some(config.hooks.claude_code.stop_volume.unwrap_or(100)); - } - - // CLI volume (50) takes priority over hook config (80) - assert_eq!(stop_tts_opts.volume, Some(50)); - } - - #[test] - fn test_volume_override_applies_to_provider_config() { - use crate::config::TtsProviderConfig; - - let provider = TtsProviderConfig { - name: "google".to_string(), - model: Some("gemini-2.5-flash-preview-tts".to_string()), - voice: None, - api_key: None, - rate: None, - volume: Some(100), // Provider default - path: None, - service_account_key: None, - language_code: None, - speed: None, - stability: None, - style: None, - style_prompt: None, - }; - - let volume_override = Some(60u32); - let mut config_with_volume = provider.clone(); - if let Some(vol) = volume_override { - config_with_volume.volume = Some(vol); - } - - // Hook-level volume (60) overrides provider-level (100) - assert_eq!(config_with_volume.volume, Some(60)); - } - - #[test] - fn test_volume_override_none_preserves_provider_volume() { - use crate::config::TtsProviderConfig; - - let provider = TtsProviderConfig { - name: "google".to_string(), - model: Some("gemini-2.5-flash-preview-tts".to_string()), - voice: None, - api_key: None, - rate: None, - volume: Some(100), - path: None, - service_account_key: None, - language_code: None, - speed: None, - stability: None, - style: None, - style_prompt: None, - }; - - let volume_override: Option = None; - let mut config_with_volume = provider.clone(); - if let Some(vol) = volume_override { - config_with_volume.volume = Some(vol); - } - - // No override → provider volume preserved - assert_eq!(config_with_volume.volume, Some(100)); - } - - #[test] - fn test_volume_override_applies_to_provider_without_volume() { - use crate::config::TtsProviderConfig; - - let provider = TtsProviderConfig { - name: "google".to_string(), - model: Some("gemini-2.5-flash-preview-tts".to_string()), - voice: None, - api_key: None, - rate: None, - volume: None, // No provider volume set - path: None, - service_account_key: None, - language_code: None, - speed: None, - stability: None, - style: None, - style_prompt: None, - }; - - let volume_override = Some(80u32); - let mut config_with_volume = provider.clone(); - if let Some(vol) = volume_override { - config_with_volume.volume = Some(vol); - } - - // Hook-level volume applies even when provider has no volume - assert_eq!(config_with_volume.volume, Some(80)); - } - - #[test] - fn test_auto_engine_propagates_volume_to_tts_opts() { - // Simulate the full flow: config → TtsOptions → speak_text - let mut config = SumvoxConfig::default(); - config.hooks.claude_code.stop_volume = Some(70); - config.hooks.claude_code.stop_tts_provider = Some("auto".to_string()); - - let tts_opts = TtsOptions::default(); - - // Replicate handle_stop logic - let mut stop_tts_opts = tts_opts.clone(); - if let Some(ref provider) = config.hooks.claude_code.stop_tts_provider { - stop_tts_opts.engine = provider.clone(); - } - if stop_tts_opts.volume.is_none() { - stop_tts_opts.volume = Some(config.hooks.claude_code.stop_volume.unwrap_or(100)); - } - - assert_eq!(stop_tts_opts.engine, "auto"); - assert_eq!(stop_tts_opts.volume, Some(70)); - - // In speak_text, Auto mode passes tts_opts.volume to speak_with_provider_fallback - // which applies it as volume_override to each provider config - let tts_engine: TtsEngine = stop_tts_opts.engine.parse().unwrap(); - assert_eq!(tts_engine, TtsEngine::Auto); - assert_eq!(stop_tts_opts.volume, Some(70)); - } - // ── Contract 1: last_assistant_message deserialization ────────────── #[test] diff --git a/src/llm/anthropic.rs b/src/llm/anthropic.rs index 8309065..7d3648f 100644 --- a/src/llm/anthropic.rs +++ b/src/llm/anthropic.rs @@ -261,66 +261,6 @@ mod tests { assert!(matches!(result.unwrap_err(), LlmError::Unavailable(_))); } - // ── C4: AnthropicRequestSerialization ─────────────────────────────── - - /// Build an AnthropicRequest the same way generate() does, for test assertions. - fn build_anthropic_request() -> AnthropicRequest { - AnthropicRequest { - model: "claude-haiku-4-5".to_string(), - max_tokens: 100, - messages: vec![Message { - role: "user".to_string(), - content: "Test".to_string(), - }], - system: None, - } - } - - #[test] - fn test_c4_disable_thinking_true_no_thinking_key() { - let req = build_anthropic_request(); - let val = serde_json::to_value(&req).unwrap(); - assert!( - val.get("thinking").is_none(), - "thinking key must be absent when disable_thinking=true" - ); - } - - #[test] - fn test_c4_disable_thinking_false_no_thinking_key() { - let req = build_anthropic_request(); - let val = serde_json::to_value(&req).unwrap(); - assert!( - val.get("thinking").is_none(), - "thinking key must be absent when disable_thinking=false" - ); - } - - #[test] - fn test_c4_any_model_name_no_thinking_key() { - // Model name must not influence whether thinking is sent - for model in &[ - "claude-3-opus", - "claude-sonnet-4", - "claude-haiku-4-5-20251001", - ] { - let req = AnthropicRequest { - model: model.to_string(), - max_tokens: 100, - messages: vec![Message { - role: "user".to_string(), - content: "Hi".to_string(), - }], - system: None, - }; - let val = serde_json::to_value(&req).unwrap(); - assert!( - val.get("thinking").is_none(), - "thinking key must be absent for model {model}" - ); - } - } - // Integration test - requires actual API key #[tokio::test] #[ignore = "e2e-network"] diff --git a/src/llm/gemini.rs b/src/llm/gemini.rs index a0896cd..cb3876f 100644 --- a/src/llm/gemini.rs +++ b/src/llm/gemini.rs @@ -325,47 +325,20 @@ mod tests { // ── C3: GeminiRequestSerialization ────────────────────────────────── - fn make_generation_config(disable_thinking: bool) -> GenerationConfig { - let thinking_config = if disable_thinking { - Some(ThinkingConfig { thinking_budget: 0 }) - } else { - None - }; - GenerationConfig { + #[test] + fn test_c3_thinking_config_wire_format() { + let config = |thinking_config| GenerationConfig { temperature: 0.3, max_output_tokens: 100, thinking_config, - } - } - - #[test] - fn test_c3_disable_thinking_true_sets_thinking_budget_zero() { - let config = make_generation_config(true); - let val = serde_json::to_value(&config).unwrap(); - assert_eq!(val["thinkingConfig"]["thinkingBudget"], 0); - } - - #[test] - fn test_c3_disable_thinking_false_omits_thinking_config() { - let config = make_generation_config(false); - let val = serde_json::to_value(&config).unwrap(); - assert!(val.get("thinkingConfig").is_none()); - } + }; - /// Past heuristic would exclude gemini-1.5-flash from thinking params. - /// Now disable_thinking=true always sets thinkingConfig regardless of model name. - #[test] - fn test_c3_gemini_1_5_flash_disable_thinking_true() { - let config = make_generation_config(true); - let val = serde_json::to_value(&config).unwrap(); - assert_eq!(val["thinkingConfig"]["thinkingBudget"], 0); - } + let disabled = + serde_json::to_value(config(Some(ThinkingConfig { thinking_budget: 0 }))).unwrap(); + assert_eq!(disabled["thinkingConfig"]["thinkingBudget"], 0); - #[test] - fn test_c3_gemini_1_5_flash_disable_thinking_false() { - let config = make_generation_config(false); - let val = serde_json::to_value(&config).unwrap(); - assert!(val.get("thinkingConfig").is_none()); + let omitted = serde_json::to_value(config(None)).unwrap(); + assert!(omitted.get("thinkingConfig").is_none()); } // Integration test - requires actual API key diff --git a/src/llm/ollama.rs b/src/llm/ollama.rs index 3d2160e..7a10298 100644 --- a/src/llm/ollama.rs +++ b/src/llm/ollama.rs @@ -202,64 +202,28 @@ mod tests { // ── C2: OllamaRequestSerialization ────────────────────────────────── - fn make_request(disable_thinking: bool) -> GenerationRequest { - GenerationRequest { - system_message: None, - prompt: "Hello".to_string(), - max_tokens: 100, - temperature: 0.3, - disable_thinking, - } - } - #[test] - fn test_c2_disable_thinking_true_sets_top_level_think_false() { - let request = make_request(true); - let ollama_req = OllamaRequest { + fn test_c2_think_wire_format() { + let request = |think| OllamaRequest { model: "llama3.2".to_string(), - prompt: request.prompt.clone(), + prompt: "Hello".to_string(), stream: false, options: OllamaOptions { - temperature: request.temperature, - num_predict: request.max_tokens, - }, - system: request.system_message.clone(), - think: if request.disable_thinking { - Some(false) - } else { - None + temperature: 0.3, + num_predict: 100, }, + system: None, + think, }; - let val = serde_json::to_value(&ollama_req).unwrap(); - assert_eq!(val["think"], serde_json::Value::Bool(false)); - // Must NOT appear inside options - assert!(val["options"].get("think").is_none()); - } - - #[test] - fn test_c2_disable_thinking_false_omits_think_key() { - let request = make_request(false); - let ollama_req = OllamaRequest { - model: "llama3.2".to_string(), - prompt: request.prompt.clone(), - stream: false, - options: OllamaOptions { - temperature: request.temperature, - num_predict: request.max_tokens, - }, - system: request.system_message.clone(), - think: if request.disable_thinking { - Some(false) - } else { - None - }, - }; + let disabled = serde_json::to_value(request(Some(false))).unwrap(); + assert_eq!(disabled["think"], serde_json::Value::Bool(false)); + // think sits at the top level, never inside options + assert!(disabled["options"].get("think").is_none()); - let val = serde_json::to_value(&ollama_req).unwrap(); - // think key must be absent at all levels - assert!(val.get("think").is_none()); - assert!(val["options"].get("think").is_none()); + let omitted = serde_json::to_value(request(None)).unwrap(); + assert!(omitted.get("think").is_none()); + assert!(omitted["options"].get("think").is_none()); } // Integration test - requires actual Ollama service running diff --git a/src/llm/openai.rs b/src/llm/openai.rs index 33e11c0..57696d5 100644 --- a/src/llm/openai.rs +++ b/src/llm/openai.rs @@ -322,91 +322,31 @@ mod tests { // ── C5: OpenAIRequestSerialization ─────────────────────────────────── - fn build_openai_request(model: &str, disable_thinking: bool) -> OpenAIRequest { - let reasoning_effort = if disable_thinking { - Some("low".to_string()) - } else { - None - }; - let (max_completion_tokens, max_tokens, temperature) = if is_reasoning_model(model) { - (Some(100u32), None, None) - } else { - (None, Some(100u32), Some(0.3f32)) - }; - OpenAIRequest { - model: model.to_string(), + #[test] + fn test_c5_request_wire_format() { + let request = |reasoning: bool| OpenAIRequest { + model: "m".to_string(), messages: vec![Message { role: "user".to_string(), content: "Test".to_string(), }], - max_completion_tokens, - max_tokens, - temperature, - reasoning_effort, - } - } - - #[test] - fn test_c5_disable_thinking_true_sets_reasoning_effort_low() { - for model in &["gpt-4o", "o3-mini", "gpt-5-pro"] { - let req = build_openai_request(model, true); - let val = serde_json::to_value(&req).unwrap(); - assert_eq!( - val["reasoning_effort"], - serde_json::Value::String("low".to_string()), - "model={model} should have reasoning_effort=low" - ); - } - } - - #[test] - fn test_c5_disable_thinking_false_omits_reasoning_effort() { - for model in &["gpt-4o", "o3-mini", "gpt-5-pro"] { - let req = build_openai_request(model, false); - let val = serde_json::to_value(&req).unwrap(); - assert!( - val.get("reasoning_effort").is_none(), - "model={model} must not have reasoning_effort when disable_thinking=false" - ); - } - } - - // ── A2: reasoning model branching ──────────────────────────────────── - - #[test] - fn test_a2_reasoning_model_uses_max_completion_tokens() { - let req = build_openai_request("o3-mini", false); - let val = serde_json::to_value(&req).unwrap(); - assert!( - val.get("max_completion_tokens").is_some(), - "o3-mini must have max_completion_tokens" - ); - assert!( - val.get("max_tokens").is_none(), - "o3-mini must not have max_tokens" - ); - assert!( - val.get("temperature").is_none(), - "o3-mini must not have temperature" - ); - } + max_completion_tokens: reasoning.then_some(100u32), + max_tokens: (!reasoning).then_some(100u32), + temperature: (!reasoning).then_some(0.3f32), + reasoning_effort: reasoning.then(|| "low".to_string()), + }; - #[test] - fn test_a2_standard_model_uses_max_tokens_and_temperature() { - let req = build_openai_request("gpt-4o", false); - let val = serde_json::to_value(&req).unwrap(); - assert!( - val.get("max_tokens").is_some(), - "gpt-4o must have max_tokens" - ); - assert!( - val.get("temperature").is_some(), - "gpt-4o must have temperature" - ); - assert!( - val.get("max_completion_tokens").is_none(), - "gpt-4o must not have max_completion_tokens" - ); + let reasoning = serde_json::to_value(request(true)).unwrap(); + assert_eq!(reasoning["reasoning_effort"], "low"); + assert!(reasoning.get("max_completion_tokens").is_some()); + assert!(reasoning.get("max_tokens").is_none()); + assert!(reasoning.get("temperature").is_none()); + + let standard = serde_json::to_value(request(false)).unwrap(); + assert!(standard.get("reasoning_effort").is_none()); + assert!(standard.get("max_tokens").is_some()); + assert!(standard.get("temperature").is_some()); + assert!(standard.get("max_completion_tokens").is_none()); } #[test] diff --git a/src/notify_log.rs b/src/notify_log.rs index 32cd450..f7418fd 100644 --- a/src/notify_log.rs +++ b/src/notify_log.rs @@ -58,14 +58,3 @@ pub fn set_now_playing(path: &Path) { let _ = fs::create_dir_all(&dir); let _ = fs::write(dir.join("now_playing"), path.to_string_lossy().as_bytes()); } - -#[cfg(test)] -mod tests { - #[test] - fn record_line_is_single_line() { - // The invariant the menu app depends on: one entry == one line. - let text = "line1\nline2\rline3"; - let flattened = text.replace(['\n', '\r'], " "); - assert!(!flattened.contains('\n') && !flattened.contains('\r')); - } -} diff --git a/src/tts/cloud_tts_auth.rs b/src/tts/cloud_tts_auth.rs index c7c519f..c824b2c 100644 --- a/src/tts/cloud_tts_auth.rs +++ b/src/tts/cloud_tts_auth.rs @@ -178,11 +178,17 @@ mod tests { assert_eq!(sa.private_key, "test-private-key-placeholder"); } - #[test] - fn test_invalid_json() { - let auth = CloudTtsAuth::new("not json".to_string()); - let result = serde_json::from_str::(&auth.service_account_json); - assert!(result.is_err()); + #[tokio::test] + async fn test_get_token_rejects_malformed_service_account() { + let err = CloudTtsAuth::new("not json".to_string()) + .get_token() + .await + .unwrap_err(); + assert!(err.to_string().contains("Invalid service account JSON")); + + // Valid JSON with a non-PEM key fails while signing, before any HTTP call + let sa = r#"{"client_email":"a@b.c","private_key":"not a pem key"}"#; + assert!(CloudTtsAuth::new(sa.to_string()).get_token().await.is_err()); } #[test] diff --git a/src/tts/openai.rs b/src/tts/openai.rs index 44fb8b3..ba98afe 100644 --- a/src/tts/openai.rs +++ b/src/tts/openai.rs @@ -248,20 +248,4 @@ mod tests { assert!(!p.speak("").await.unwrap()); assert!(!p.speak(" ").await.unwrap()); } - - #[test] - fn test_truncation_respects_char_boundaries() { - // 4096-char limit is characters, not bytes: a multibyte string longer - // than the limit must slice on a char boundary without panicking. - let text: String = "測".repeat(MAX_TEXT_LENGTH + 10); - let truncated = match text.char_indices().nth(MAX_TEXT_LENGTH) { - Some((byte_idx, _)) => &text[..byte_idx], - None => &text, - }; - assert_eq!(truncated.chars().count(), MAX_TEXT_LENGTH); - - // A string at or under the limit passes through untouched. - let short: String = "測".repeat(MAX_TEXT_LENGTH); - assert!(short.char_indices().nth(MAX_TEXT_LENGTH).is_none()); - } } From afdf7f669a7840650b715f67385d79d2295f77d4 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 11:48:10 +0800 Subject: [PATCH 07/20] test: remove derive and default-literal tests Drops tests that only read back constructor arguments, Default literals, derive output and constants. Assertions that kept trivial trait methods covered moved into the surviving tests of the same file. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/audio/file.rs | 28 +----- src/audio/player.rs | 31 +++---- src/cli.rs | 166 ------------------------------------ src/config.rs | 134 ----------------------------- src/error.rs | 32 ------- src/hooks/claude_code.rs | 52 ----------- src/llm/anthropic.rs | 14 +-- src/llm/mod.rs | 37 -------- src/llm/ollama.rs | 37 +------- src/llm/openai.rs | 14 +-- src/main.rs | 69 --------------- src/queue.rs | 13 --- src/tts/cloud_tts.rs | 21 +---- src/tts/cloud_tts_auth.rs | 23 ----- src/tts/elevenlabs.rs | 36 +------- src/tts/espeak.rs | 22 ----- src/tts/google.rs | 46 +--------- src/tts/macos.rs | 24 +----- src/tts/mod.rs | 35 ++++---- src/tts/openai.rs | 29 +------ src/tts/piper.rs | 22 ----- src/tts/xai.rs | 15 +--- tests/cli.rs | 10 --- tests/recommended_config.rs | 43 ---------- 24 files changed, 37 insertions(+), 916 deletions(-) diff --git a/src/audio/file.rs b/src/audio/file.rs index 681ecab..79e0b88 100644 --- a/src/audio/file.rs +++ b/src/audio/file.rs @@ -156,6 +156,7 @@ mod tests { assert_eq!(provider.name(), "audio_file"); assert_eq!(provider.volume, 80); assert!(matches!(provider.mode, AudioFileMode::SingleFile(_))); + assert_eq!(provider.estimate_cost(100), 0.0); } #[test] @@ -225,22 +226,6 @@ mod tests { .contains("No audio files found")); } - #[test] - fn test_volume_mapping() { - let mut temp_file = NamedTempFile::new().unwrap(); - temp_file.write_all(b"fake audio").unwrap(); - let path = temp_file.path().to_path_buf(); - - let provider_0 = AudioFileProvider::new(path.clone(), 0).unwrap(); - assert_eq!(provider_0.volume, 0); - - let provider_50 = AudioFileProvider::new(path.clone(), 50).unwrap(); - assert_eq!(provider_50.volume, 50); - - let provider_100 = AudioFileProvider::new(path, 100).unwrap(); - assert_eq!(provider_100.volume, 100); - } - #[test] fn test_is_available_single_file() { let mut temp_file = NamedTempFile::new().unwrap(); @@ -257,15 +242,4 @@ mod tests { let provider = AudioFileProvider::new(temp_dir.path().to_path_buf(), 100).unwrap(); assert!(provider.is_available()); } - - #[test] - fn test_estimate_cost_is_zero() { - let mut temp_file = NamedTempFile::new().unwrap(); - temp_file.write_all(b"fake audio").unwrap(); - let path = temp_file.path().to_path_buf(); - - let provider = AudioFileProvider::new(path, 100).unwrap(); - assert_eq!(provider.estimate_cost(100), 0.0); - assert_eq!(provider.estimate_cost(10000), 0.0); - } } diff --git a/src/audio/player.rs b/src/audio/player.rs index f0881ee..d73159d 100644 --- a/src/audio/player.rs +++ b/src/audio/player.rs @@ -522,6 +522,11 @@ mod tests { ); } + #[test] + fn test_default_candidates_are_never_empty() { + assert!(!default_candidates().is_empty()); + } + #[test] fn test_select_player_rejects_aplay_at_zero_volume() { // Deterministic with or without aplay installed: volume 0 disqualifies @@ -532,6 +537,11 @@ mod tests { #[test] fn test_select_player_falls_past_aplay_at_zero_volume() { assert_eq!(select_player(&["aplay", "sh"], 0), Some("sh".to_string())); + // Above zero volume aplay is a valid choice, so either candidate may win + assert!(matches!( + select_player(&["aplay", "sh"], 50).as_deref(), + Some("aplay" | "sh") + )); } #[test] @@ -712,25 +722,4 @@ mod tests { start.elapsed() ); } - - #[test] - fn test_default_candidates_are_platform_specific() { - if cfg!(target_os = "macos") { - assert_eq!(default_candidates(), &["afplay"]); - } else { - assert_eq!( - default_candidates(), - &["paplay", "pw-play", "ffplay", "mpv", "aplay"] - ); - } - } - - #[test] - fn test_select_player_allows_aplay_above_zero_volume() { - // Consistency assertion: correct on machines with and without aplay. - assert_eq!( - select_player(&["aplay"], 50).is_some(), - find_on_path("aplay").is_some() - ); - } } diff --git a/src/cli.rs b/src/cli.rs index 6ac85b5..4bb08e1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -135,174 +135,8 @@ mod tests { use super::*; use clap::CommandFactory; - #[test] - fn test_parse_say_command() { - let cli = Cli::try_parse_from(["sumvox", "say", "Hello world"]).unwrap(); - - match cli.command { - Some(Commands::Say(args)) => { - assert_eq!(args.text, "Hello world"); - assert_eq!(args.tts, "auto"); - assert_eq!(args.rate, 200); - assert_eq!(args.voice, None); - assert_eq!(args.volume, None); - } - _ => panic!("Expected Say command"), - } - } - - #[test] - fn test_parse_say_with_options() { - let cli = Cli::try_parse_from([ - "sumvox", "say", "Hello", "--tts", "macos", "--voice", "Tingting", "--rate", "180", - "--volume", "75", - ]) - .unwrap(); - - match cli.command { - Some(Commands::Say(args)) => { - assert_eq!(args.text, "Hello"); - assert_eq!(args.tts, "macos"); - assert_eq!(args.voice, Some("Tingting".to_string())); - assert_eq!(args.rate, 180); - assert_eq!(args.volume, Some(75)); - } - _ => panic!("Expected Say command"), - } - } - - #[test] - fn test_parse_sum_command() { - let cli = Cli::try_parse_from(["sumvox", "sum", "Long text to summarize"]).unwrap(); - - match cli.command { - Some(Commands::Sum(args)) => { - assert_eq!(args.text, "Long text to summarize"); - assert_eq!(args.provider, None); - assert_eq!(args.model, None); - assert!(!args.no_speak); - } - _ => panic!("Expected Sum command"), - } - } - - #[test] - fn test_parse_sum_with_options() { - let cli = Cli::try_parse_from([ - "sumvox", - "sum", - "-", - "--provider", - "google", - "--model", - "gemini-2.5-flash", - "--no-speak", - ]) - .unwrap(); - - match cli.command { - Some(Commands::Sum(args)) => { - assert_eq!(args.text, "-"); - assert_eq!(args.provider, Some("google".to_string())); - assert_eq!(args.model, Some("gemini-2.5-flash".to_string())); - assert!(args.no_speak); - } - _ => panic!("Expected Sum command"), - } - } - - #[test] - fn test_parse_json_command() { - let cli = Cli::try_parse_from(["sumvox", "json"]).unwrap(); - - match cli.command { - Some(Commands::Json(args)) => { - assert_eq!(args.format, "auto"); - } - _ => panic!("Expected Json command"), - } - } - - #[test] - fn test_parse_json_with_format() { - let cli = Cli::try_parse_from(["sumvox", "json", "--format", "claude-code"]).unwrap(); - - match cli.command { - Some(Commands::Json(args)) => { - assert_eq!(args.format, "claude-code"); - } - _ => panic!("Expected Json command"), - } - } - - #[test] - fn test_parse_init_command() { - let cli = Cli::try_parse_from(["sumvox", "init"]).unwrap(); - - match cli.command { - Some(Commands::Init(args)) => { - assert!(!args.force); - } - _ => panic!("Expected Init command"), - } - } - - #[test] - fn test_parse_init_with_force() { - let cli = Cli::try_parse_from(["sumvox", "init", "--force"]).unwrap(); - - match cli.command { - Some(Commands::Init(args)) => { - assert!(args.force); - } - _ => panic!("Expected Init command"), - } - } - #[test] fn test_cli_verify() { Cli::command().debug_assert(); } - - #[test] - fn test_say_help_documents_local_engines() { - let rendered = Cli::try_parse_from(["sumvox", "say", "--help"]) - .unwrap_err() - .to_string(); - - assert!( - rendered.contains("espeak"), - "help missing espeak: {rendered}" - ); - assert!(rendered.contains("piper"), "help missing piper: {rendered}"); - assert!( - rendered.contains("cmn+f3"), - "help missing espeak voice form: {rendered}" - ); - assert!( - rendered.contains(".onnx"), - "help missing piper voice form: {rendered}" - ); - } - - #[test] - fn test_sum_help_documents_local_engines() { - let rendered = Cli::try_parse_from(["sumvox", "sum", "--help"]) - .unwrap_err() - .to_string(); - - assert!( - rendered.contains("espeak"), - "help missing espeak: {rendered}" - ); - assert!(rendered.contains("piper"), "help missing piper: {rendered}"); - assert!( - rendered.contains("cmn+f3"), - "help missing espeak voice form: {rendered}" - ); - assert!( - rendered.contains(".onnx"), - "help missing piper voice form: {rendered}" - ); - } } diff --git a/src/config.rs b/src/config.rs index d46d3b5..533ca39 100644 --- a/src/config.rs +++ b/src/config.rs @@ -776,18 +776,6 @@ mod tests { use std::io::Write; use tempfile::NamedTempFile; - #[test] - fn test_default_config() { - let config = SumvoxConfig::default(); - assert!(!config.llm.providers.is_empty()); - assert!(!config.tts.providers.is_empty()); - assert_eq!(config.summarization.turns, 1); - assert_eq!( - config.hooks.claude_code.notification_tts_provider, - Some("auto".to_string()) - ); - } - #[test] fn test_load_new_format() { let config_json = r#"{ @@ -978,61 +966,6 @@ mod tests { assert!(json.contains("${PROVIDER_API_KEY}")); } - #[test] - fn test_ollama_timeout_60_seconds() { - let config = LlmConfig::default(); - let ollama = config - .providers - .iter() - .find(|p| p.name == "ollama") - .unwrap(); - assert_eq!(ollama.timeout, 60); - } - - #[test] - fn test_summarization_config() { - let config = SumvoxConfig::default(); - assert_eq!(config.summarization.turns, 1); - assert!(!config.summarization.fallback_message.is_empty()); - assert!(!config - .summarization - .prompt_template - .contains("{max_length}")); - assert!(config.summarization.prompt_template.contains("{context}")); - } - - #[test] - fn test_claude_code_hook_config() { - let config = SumvoxConfig::default(); - assert!(!config.hooks.claude_code.notification_filter.is_empty()); - assert_eq!( - config.hooks.claude_code.notification_tts_provider, - Some("auto".to_string()) - ); - assert_eq!( - config.hooks.claude_code.stop_tts_provider, - Some("auto".to_string()) - ); - } - - #[test] - fn test_max_tokens_default_10000() { - let params = LlmParameters::default(); - assert_eq!(params.max_tokens, 10000); - } - - #[test] - fn test_disable_thinking_default_false() { - let params = LlmParameters::default(); - assert!(!params.disable_thinking); - } - - #[test] - fn test_config_path_is_xdg() { - let path = SumvoxConfig::config_path().unwrap(); - assert!(path.to_string_lossy().contains(".config/sumvox")); - } - #[test] fn test_load_yaml_format() { let config_yaml = r#" @@ -1133,13 +1066,6 @@ tts: .contains("Stop hook volume")); } - #[test] - fn test_hook_volume_defaults() { - let config = SumvoxConfig::default(); - assert_eq!(config.hooks.claude_code.notification_volume, None); - assert_eq!(config.hooks.claude_code.stop_volume, None); - } - #[test] fn test_service_account_key_reads_file() { use std::io::Write; @@ -1192,17 +1118,6 @@ tts: assert_eq!(config.get_service_account_key(), None); } - #[test] - fn test_language_code_deserialization() { - let config_json = r#"{ - "name": "cloud_tts", - "language_code": "zh-TW" - }"#; - - let config: TtsProviderConfig = serde_json::from_str(config_json).unwrap(); - assert_eq!(config.language_code, Some("zh-TW".to_string())); - } - // ── ContentSource tests ────────────────────────────────────────── #[test] @@ -1254,14 +1169,6 @@ turns = 1 assert!(result.is_err()); } - #[test] - fn test_summarization_config_default_unchanged() { - let config = SummarizationConfig::default(); - assert_eq!(config.content_source, ContentSource::Transcript); - assert_eq!(config.turns, 1); - assert_eq!(config.fallback_message, "Task completed"); - } - // ── C1: effective_disable_thinking resolver ────────────────────────── fn make_provider(override_val: Option) -> LlmProviderConfig { @@ -1312,45 +1219,4 @@ turns = 1 } // ── C6: per-provider disable_thinking TOML deserialization ────────── - - #[test] - fn test_c6_provider_disable_thinking_absent_is_none() { - let toml = r#" -[[llm.providers]] -name = "google" -model = "gemini-2.5-flash" -api_key = "${KEY}" -timeout = 10 -"#; - let config: SumvoxConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.llm.providers[0].disable_thinking, None); - } - - #[test] - fn test_c6_provider_disable_thinking_true() { - let toml = r#" -[[llm.providers]] -name = "google" -model = "gemini-2.5-flash" -api_key = "${KEY}" -timeout = 10 -disable_thinking = true -"#; - let config: SumvoxConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.llm.providers[0].disable_thinking, Some(true)); - } - - #[test] - fn test_c6_provider_disable_thinking_false() { - let toml = r#" -[[llm.providers]] -name = "google" -model = "gemini-2.5-flash" -api_key = "${KEY}" -timeout = 10 -disable_thinking = false -"#; - let config: SumvoxConfig = toml::from_str(toml).unwrap(); - assert_eq!(config.llm.providers[0].disable_thinking, Some(false)); - } } diff --git a/src/error.rs b/src/error.rs index c869feb..2e7ff95 100644 --- a/src/error.rs +++ b/src/error.rs @@ -37,35 +37,3 @@ pub enum LlmError { pub type Result = std::result::Result; pub type LlmResult = std::result::Result; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_error_display() { - let err = VoiceError::Config("missing field".to_string()); - assert_eq!(err.to_string(), "Configuration error: missing field"); - } - - #[test] - fn test_error_from_io() { - let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); - let voice_err: VoiceError = io_err.into(); - assert!(matches!(voice_err, VoiceError::Io(_))); - } - - #[test] - fn test_error_from_json() { - let json_str = "{invalid json}"; - let json_err = serde_json::from_str::(json_str).unwrap_err(); - let voice_err: VoiceError = json_err.into(); - assert!(matches!(voice_err, VoiceError::Json(_))); - } - - #[test] - fn test_queue_error() { - let err = VoiceError::Queue("lock timeout".to_string()); - assert_eq!(err.to_string(), "Queue error: lock timeout"); - } -} diff --git a/src/hooks/claude_code.rs b/src/hooks/claude_code.rs index 5ea2277..68bdf6e 100644 --- a/src/hooks/claude_code.rs +++ b/src/hooks/claude_code.rs @@ -337,62 +337,10 @@ mod tests { ); } - #[test] - fn test_tts_options_default() { - let opts = TtsOptions::default(); - assert_eq!(opts.engine, "auto"); - assert_eq!(opts.rate, 200); - assert!(opts.voice.is_none()); - assert!(opts.volume.is_none()); - } - - #[test] - fn test_llm_options_default() { - let opts = LlmOptions::default(); - assert!(opts.provider.is_none()); - assert!(opts.model.is_none()); - assert_eq!(opts.timeout, 10); - } - // ── Volume override tests ────────────────────────────────────────── // ── Contract 1: last_assistant_message deserialization ────────────── - #[test] - fn test_last_assistant_message_absent() { - let json = r#"{ - "session_id": "s1", - "transcript_path": "/tmp/t.jsonl", - "hook_event_name": "Stop" - }"#; - let input = ClaudeCodeInput::parse(json).unwrap(); - assert_eq!(input.last_assistant_message, None); - } - - #[test] - fn test_last_assistant_message_present() { - let json = r#"{ - "session_id": "s1", - "transcript_path": "/tmp/t.jsonl", - "hook_event_name": "Stop", - "last_assistant_message": "Done" - }"#; - let input = ClaudeCodeInput::parse(json).unwrap(); - assert_eq!(input.last_assistant_message, Some("Done".to_string())); - } - - #[test] - fn test_last_assistant_message_empty() { - let json = r#"{ - "session_id": "s1", - "transcript_path": "/tmp/t.jsonl", - "hook_event_name": "Stop", - "last_assistant_message": "" - }"#; - let input = ClaudeCodeInput::parse(json).unwrap(); - assert_eq!(input.last_assistant_message, Some("".to_string())); - } - // ── Contract 3: select_stop_context_source logic ──────────────────── #[test] diff --git a/src/llm/anthropic.rs b/src/llm/anthropic.rs index 7d3648f..c51a133 100644 --- a/src/llm/anthropic.rs +++ b/src/llm/anthropic.rs @@ -190,19 +190,6 @@ impl LlmProvider for AnthropicProvider { mod tests { use super::*; - #[test] - fn test_anthropic_provider_creation() { - let provider = AnthropicProvider::with_base_url( - "test-key".to_string(), - "claude-haiku-4-5".to_string(), - ANTHROPIC_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert_eq!(provider.name(), "anthropic"); - assert!(provider.is_available()); - } - #[test] fn test_is_available_with_key() { let provider = AnthropicProvider::with_base_url( @@ -212,6 +199,7 @@ mod tests { Duration::from_secs(10), ); + assert_eq!(provider.name(), "anthropic"); assert!(provider.is_available()); } diff --git a/src/llm/mod.rs b/src/llm/mod.rs index e8cb029..cc359af 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -45,40 +45,3 @@ pub trait LlmProvider: Send + Sync { /// Generate text from prompt async fn generate(&self, request: &GenerationRequest) -> LlmResult; } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_generation_request_creation() { - let request = GenerationRequest { - system_message: Some("System instruction".to_string()), - prompt: "Test prompt".to_string(), - max_tokens: 100, - temperature: 0.3, - disable_thinking: false, - }; - - assert_eq!( - request.system_message, - Some("System instruction".to_string()) - ); - assert_eq!(request.prompt, "Test prompt"); - assert_eq!(request.max_tokens, 100); - assert_eq!(request.temperature, 0.3); - } - - #[test] - fn test_generation_response_creation() { - let response = GenerationResponse { - text: "Generated text".to_string(), - input_tokens: 10, - output_tokens: 20, - }; - - assert_eq!(response.text, "Generated text"); - assert_eq!(response.input_tokens, 10); - assert_eq!(response.output_tokens, 20); - } -} diff --git a/src/llm/ollama.rs b/src/llm/ollama.rs index 7a10298..ffbafee 100644 --- a/src/llm/ollama.rs +++ b/src/llm/ollama.rs @@ -142,42 +142,6 @@ impl LlmProvider for OllamaProvider { mod tests { use super::*; - #[test] - fn test_ollama_provider_creation() { - let provider = OllamaProvider::with_base_url( - "http://localhost:11434".to_string(), - "llama3.1".to_string(), - Duration::from_secs(30), - ); - - assert_eq!(provider.name(), "ollama"); - assert_eq!(provider.base_url, "http://localhost:11434"); - assert!(provider.is_available()); - } - - #[test] - fn test_ollama_provider_with_custom_base_url() { - let provider = OllamaProvider::with_base_url( - "http://custom:11434".to_string(), - "llama3.1".to_string(), - Duration::from_secs(30), - ); - - assert_eq!(provider.base_url, "http://custom:11434"); - } - - #[test] - fn test_is_available() { - let provider = OllamaProvider::with_base_url( - "http://localhost:11434".to_string(), - "llama3.1".to_string(), - Duration::from_secs(30), - ); - - // Ollama local service is always considered available - assert!(provider.is_available()); - } - #[test] fn test_extract_model_name() { let provider = OllamaProvider::with_base_url( @@ -187,6 +151,7 @@ mod tests { ); assert_eq!(provider.extract_model_name(), "llama3.1"); + assert!(provider.is_available()); } #[test] diff --git a/src/llm/openai.rs b/src/llm/openai.rs index 57696d5..007ab65 100644 --- a/src/llm/openai.rs +++ b/src/llm/openai.rs @@ -225,19 +225,6 @@ impl LlmProvider for OpenAIProvider { mod tests { use super::*; - #[test] - fn test_openai_provider_creation() { - let provider = OpenAIProvider::with_base_url( - "test-key".to_string(), - "gpt-4o-mini".to_string(), - OPENAI_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert_eq!(provider.name(), "openai"); - assert!(provider.is_available()); - } - #[test] fn test_is_available_with_key() { let provider = OpenAIProvider::with_base_url( @@ -247,6 +234,7 @@ mod tests { Duration::from_secs(10), ); + assert_eq!(provider.name(), "openai"); assert!(provider.is_available()); } diff --git a/src/main.rs b/src/main.rs index 085f63d..f5dc3c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -310,72 +310,3 @@ async fn handle_init(args: InitArgs) -> Result<()> { Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use config::{effective_disable_thinking, LlmParameters, LlmProviderConfig}; - - #[test] - fn test_a1_fallback_loop_per_provider_disable_thinking() { - // Verify each provider in the fallback list gets its own effective disable_thinking - let params = LlmParameters { - max_tokens: 100, - temperature: 0.3, - disable_thinking: false, // global: false - }; - let providers = [ - LlmProviderConfig { - name: "google".to_string(), - model: "gemini-2.5-flash".to_string(), - api_key: None, - base_url: None, - timeout: 10, - disable_thinking: None, // inherits global: false - }, - LlmProviderConfig { - name: "openai".to_string(), - model: "o3-mini".to_string(), - api_key: None, - base_url: None, - timeout: 10, - disable_thinking: Some(true), // override: true - }, - ]; - - let google_dt = effective_disable_thinking(&providers[0], ¶ms); - let openai_dt = effective_disable_thinking(&providers[1], ¶ms); - - assert!( - !google_dt, - "google must inherit global disable_thinking=false" - ); - assert!( - openai_dt, - "openai must use per-provider disable_thinking=true" - ); - } - - #[test] - fn test_tts_options_from_say_args() { - let args = SayArgs { - text: "Hello".to_string(), - tts: "macos".to_string(), - voice: Some("Tingting".to_string()), - rate: 200, - volume: Some(80), - }; - - let opts = TtsOptions { - engine: args.tts, - voice: args.voice, - rate: args.rate, - volume: args.volume, - }; - - assert_eq!(opts.engine, "macos"); - assert_eq!(opts.voice, Some("Tingting".to_string())); - assert_eq!(opts.rate, 200); - assert_eq!(opts.volume, Some(80)); - } -} diff --git a/src/queue.rs b/src/queue.rs index 276c19d..7ed0a6f 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -127,19 +127,6 @@ mod tests { use super::*; use tempfile::tempdir; - #[test] - fn test_queue_creation() { - let queue = NotificationQueue::new(Some(Duration::from_secs(10))).unwrap(); - assert_eq!(queue.timeout, Duration::from_secs(10)); - assert!(queue.lock_file_path.to_string_lossy().contains(".sumvox")); - } - - #[test] - fn test_queue_default_timeout() { - let queue = NotificationQueue::new(None).unwrap(); - assert_eq!(queue.timeout, Duration::from_secs(30)); - } - #[tokio::test] async fn test_lock_acquire_when_available() { let temp_dir = tempdir().unwrap(); diff --git a/src/tts/cloud_tts.rs b/src/tts/cloud_tts.rs index 7481fc3..9fa5b80 100644 --- a/src/tts/cloud_tts.rs +++ b/src/tts/cloud_tts.rs @@ -293,15 +293,10 @@ mod tests { ) } - #[test] - fn test_provider_name() { - let p = create_test_provider(); - assert_eq!(p.name(), "cloud_tts"); - } - #[test] fn test_is_available_with_json() { let p = create_test_provider(); + assert_eq!(p.name(), "cloud_tts"); assert!(p.is_available()); } @@ -347,20 +342,6 @@ mod tests { assert_eq!(p.language_code, "en-US"); } - #[test] - fn test_custom_voice() { - let p = CloudTtsProvider::new( - "sa".into(), - "zh-TW-Wavenet-B".into(), - Some("zh-TW".into()), - None, - None, - 100, - ); - assert_eq!(p.voice, "zh-TW-Wavenet-B"); - assert_eq!(p.language_code, "zh-TW"); - } - #[test] fn test_split_text_short() { let text = "Short text."; diff --git a/src/tts/cloud_tts_auth.rs b/src/tts/cloud_tts_auth.rs index c824b2c..288245e 100644 --- a/src/tts/cloud_tts_auth.rs +++ b/src/tts/cloud_tts_auth.rs @@ -162,22 +162,6 @@ impl CloudTtsAuth { mod tests { use super::*; - #[test] - fn test_parse_service_account_valid() { - let json = r#"{ - "type": "service_account", - "client_email": "test@proj.iam.gserviceaccount.com", - "private_key": "test-private-key-placeholder", - "token_uri": "https://oauth2.googleapis.com/token" - }"#; - - let sa: std::result::Result = serde_json::from_str(json); - assert!(sa.is_ok()); - let sa = sa.unwrap(); - assert_eq!(sa.client_email, "test@proj.iam.gserviceaccount.com"); - assert_eq!(sa.private_key, "test-private-key-placeholder"); - } - #[tokio::test] async fn test_get_token_rejects_malformed_service_account() { let err = CloudTtsAuth::new("not json".to_string()) @@ -190,11 +174,4 @@ mod tests { let sa = r#"{"client_email":"a@b.c","private_key":"not a pem key"}"#; assert!(CloudTtsAuth::new(sa.to_string()).get_token().await.is_err()); } - - #[test] - fn test_missing_fields() { - let json = r#"{"type":"service_account"}"#; - let result = serde_json::from_str::(json); - assert!(result.is_err()); - } } diff --git a/src/tts/elevenlabs.rs b/src/tts/elevenlabs.rs index ac5d8d7..2f4a146 100644 --- a/src/tts/elevenlabs.rs +++ b/src/tts/elevenlabs.rs @@ -227,41 +227,6 @@ impl TtsProvider for ElevenLabsProvider { mod tests { use super::*; - #[test] - fn test_provider_creation_defaults() { - let provider = ElevenLabsProvider::new( - "test-key".to_string(), - "21m00Tcm4TlvDq8ikWAM".to_string(), - "eleven_flash_v2_5".to_string(), - None, - None, - None, - 100, - ); - assert_eq!(provider.name(), "elevenlabs"); - assert_eq!(provider.voice_id, "21m00Tcm4TlvDq8ikWAM"); - assert_eq!(provider.model_id, "eleven_flash_v2_5"); - assert_eq!(provider.volume, 100); - assert!(provider.is_available()); - } - - #[test] - fn test_provider_custom_voice_and_model() { - let provider = ElevenLabsProvider::new( - "test-key".to_string(), - "JBFqnCBsd6RMkjVDRZzb".to_string(), - "eleven_multilingual_v2".to_string(), - Some(0.85), - None, - None, - 75, - ); - assert_eq!(provider.voice_id, "JBFqnCBsd6RMkjVDRZzb"); - assert_eq!(provider.model_id, "eleven_multilingual_v2"); - assert_eq!(provider.speed, Some(0.85)); - assert_eq!(provider.volume, 75); - } - #[test] fn test_speed_clamped_to_valid_range() { let too_slow = ElevenLabsProvider::new( @@ -322,6 +287,7 @@ mod tests { None, 100, ); + assert_eq!(provider.name(), "elevenlabs"); // 1M chars × $0.00005 = $50 let cost = provider.estimate_cost(1_000_000); assert!((cost - 50.0).abs() < 0.001); diff --git a/src/tts/espeak.rs b/src/tts/espeak.rs index 5d40636..e289d59 100644 --- a/src/tts/espeak.rs +++ b/src/tts/espeak.rs @@ -326,17 +326,6 @@ mod tests { .expect("whitespace-only text must not error")); } - #[test] - fn test_is_available_tracks_the_installed_binary() { - // Consistency assertion: correct on machines with and without - // espeak-ng, so it never fails spuriously in CI. - let provider = EspeakProvider::new(None, 175, 100); - assert_eq!( - provider.is_available(), - player::find_on_path("espeak-ng").is_some() - ); - } - #[test] fn test_is_available_false_when_binary_missing() { let provider = EspeakProvider::new(None, 175, 100).with_binary("sumvox_no_such_binary"); @@ -346,17 +335,6 @@ mod tests { ); } - #[test] - fn test_estimate_cost_is_zero() { - let provider = EspeakProvider::new(Some("cmn+f3".to_string()), 175, 80); - assert_eq!(provider.estimate_cost(10_000), 0.0); - } - - #[test] - fn test_name_is_espeak() { - assert_eq!(EspeakProvider::new(None, 175, 80).name(), "espeak"); - } - #[test] fn test_espeak_args_never_carries_the_text() { // The builder has no text parameter at all — a structural guarantee, diff --git a/src/tts/google.rs b/src/tts/google.rs index 54d6e5c..8b82b66 100644 --- a/src/tts/google.rs +++ b/src/tts/google.rs @@ -267,32 +267,6 @@ impl TtsProvider for GoogleTtsProvider { mod tests { use super::*; - #[test] - fn test_google_provider_creation() { - let provider = GoogleTtsProvider::new( - "test-api-key".to_string(), - "gemini-2.5-flash-preview-tts".to_string(), - "Aoede".to_string(), - 100, - ); - assert_eq!(provider.name(), "google"); - assert_eq!(provider.voice_name, "Aoede"); - assert_eq!(provider.volume, 100); - assert!(provider.is_available()); - } - - #[test] - fn test_custom_voice() { - let provider = GoogleTtsProvider::new( - "test-api-key".to_string(), - "gemini-2.5-flash-preview-tts".to_string(), - "Charon".to_string(), - 75, - ); - assert_eq!(provider.voice_name, "Charon"); - assert_eq!(provider.volume, 75); - } - #[test] fn test_empty_api_key() { let provider = GoogleTtsProvider::new( @@ -302,24 +276,8 @@ mod tests { 100, ); assert!(!provider.is_available()); - } - - #[test] - fn test_cost_estimation() { - let provider = GoogleTtsProvider::new( - "test-api-key".to_string(), - "gemini-2.5-flash-preview-tts".to_string(), - "Aoede".to_string(), - 100, - ); - - // 50 chars (typical summary length) - let cost_50 = provider.estimate_cost(50); - assert!((cost_50 - 0.0008).abs() < 0.0001); - - // 100 chars - let cost_100 = provider.estimate_cost(100); - assert!((cost_100 - 0.0016).abs() < 0.0001); + assert_eq!(provider.name(), "google"); + assert!(provider.estimate_cost(1000) > 0.0); } #[tokio::test] diff --git a/src/tts/macos.rs b/src/tts/macos.rs index 7999cee..76b5ba2 100644 --- a/src/tts/macos.rs +++ b/src/tts/macos.rs @@ -114,34 +114,12 @@ impl TtsProvider for MacOsTtsProvider { mod tests { use super::*; - #[test] - fn test_macos_provider_creation() { - let provider = MacOsTtsProvider::new(Some("Tingting".to_string()), 180, 75); - assert_eq!(provider.name(), "macos"); - assert_eq!(provider.voice_name, Some("Tingting".to_string())); - assert_eq!(provider.rate, 180); - assert_eq!(provider.volume, 75); - } - - #[test] - fn test_estimate_cost_is_zero() { - let provider = MacOsTtsProvider::new(Some("Tingting".to_string()), 200, 100); - assert_eq!(provider.estimate_cost(100), 0.0); - assert_eq!(provider.estimate_cost(10000), 0.0); - } - - #[cfg(target_os = "macos")] - #[test] - fn test_is_available_on_macos() { - let provider = MacOsTtsProvider::new(Some("Tingting".to_string()), 200, 100); - assert!(provider.is_available()); - } - #[tokio::test] async fn test_speak_empty_message() { let provider = MacOsTtsProvider::new(Some("Tingting".to_string()), 200, 100); let result = provider.speak("").await.unwrap(); assert!(!result); + assert_eq!(provider.estimate_cost(100), 0.0); } #[tokio::test] diff --git a/src/tts/mod.rs b/src/tts/mod.rs index e4f25b2..cbc44b2 100644 --- a/src/tts/mod.rs +++ b/src/tts/mod.rs @@ -402,6 +402,22 @@ mod tests { assert_eq!("gcloud".parse::().ok(), Some(TtsEngine::Google)); assert_eq!("auto".parse::().ok(), Some(TtsEngine::Auto)); assert!("unknown".parse::().is_err()); + + // Display and FromStr agree on the canonical name of every engine + for engine in [ + TtsEngine::MacOS, + TtsEngine::Google, + TtsEngine::CloudTts, + TtsEngine::Xai, + TtsEngine::ElevenLabs, + TtsEngine::OpenAi, + TtsEngine::AudioFile, + TtsEngine::Espeak, + TtsEngine::Piper, + TtsEngine::Auto, + ] { + assert_eq!(engine.to_string().parse::().ok(), Some(engine)); + } } #[test] @@ -574,12 +590,6 @@ mod tests { ); } - #[test] - fn test_new_engines_display_as_their_canonical_tag() { - assert_eq!(TtsEngine::Espeak.to_string(), "espeak"); - assert_eq!(TtsEngine::Piper.to_string(), "piper"); - } - #[test] fn test_espeakng_is_not_an_alias() { // Only the three documented spellings map to Espeak. @@ -776,11 +786,6 @@ mod tests { ); } - #[test] - fn test_cloud_tts_display() { - assert_eq!(TtsEngine::CloudTts.to_string(), "cloud_tts"); - } - #[test] fn test_google_still_maps_to_google() { assert!(matches!( @@ -789,14 +794,6 @@ mod tests { )); } - #[test] - fn test_tts_engine_display() { - assert_eq!(TtsEngine::MacOS.to_string(), "macos"); - assert_eq!(TtsEngine::Google.to_string(), "google"); - assert_eq!(TtsEngine::CloudTts.to_string(), "cloud_tts"); - assert_eq!(TtsEngine::Auto.to_string(), "auto"); - } - #[test] #[cfg(target_os = "macos")] fn test_create_macos_tts() { diff --git a/src/tts/openai.rs b/src/tts/openai.rs index ba98afe..ee46a59 100644 --- a/src/tts/openai.rs +++ b/src/tts/openai.rs @@ -183,17 +183,6 @@ mod tests { ) } - #[test] - fn test_provider_creation() { - let p = provider(None); - assert_eq!(p.name(), "openai"); - assert_eq!(p.model, "gpt-4o-mini-tts"); - assert_eq!(p.voice, "nova"); - assert_eq!(p.speed, None); - assert_eq!(p.volume, 100); - assert!(p.is_available()); - } - #[test] fn test_unavailable_with_empty_or_placeholder_key() { let empty = OpenAiTtsProvider::new( @@ -222,23 +211,7 @@ mod tests { assert_eq!(provider(Some(5.0)).speed, Some(4.0)); assert_eq!(provider(Some(0.1)).speed, Some(0.25)); assert_eq!(provider(Some(1.5)).speed, Some(1.5)); - } - - #[test] - fn test_cost_estimation() { - let p = provider(None); - - // 1M chars × $0.000075 = $75 - assert!((p.estimate_cost(1_000_000) - 75.0).abs() < 0.001); - - // Measured: ~120 chars ≈ $0.009 - assert!((p.estimate_cost(120) - 0.009).abs() < 0.0001); - - assert_eq!(p.estimate_cost(0), 0.0); - } - - #[test] - fn test_does_not_support_audio_tags() { + assert!(provider(None).estimate_cost(1000) > 0.0); assert!(!provider(None).supports_audio_tags()); } diff --git a/src/tts/piper.rs b/src/tts/piper.rs index 0030b81..44aca90 100644 --- a/src/tts/piper.rs +++ b/src/tts/piper.rs @@ -347,17 +347,6 @@ mod tests { assert!(!provider.is_available()); } - #[test] - fn test_is_available_tracks_the_binary_when_model_exists() { - // Consistency assertion: correct on machines with and without piper. - let model = tempfile::NamedTempFile::new().expect("temp model file"); - let provider = PiperProvider::new(model.path(), 200, 100); - assert_eq!( - provider.is_available(), - player::find_on_path("piper").is_some() - ); - } - #[test] fn test_is_available_false_when_binary_missing() { let model = tempfile::NamedTempFile::new().expect("temp model file"); @@ -368,15 +357,4 @@ mod tests { "a missing engine must be skipped by the chain, not error" ); } - - #[test] - fn test_estimate_cost_is_zero() { - let provider = PiperProvider::new("/m/zh.onnx", 200, 80); - assert_eq!(provider.estimate_cost(10_000), 0.0); - } - - #[test] - fn test_name_is_piper() { - assert_eq!(PiperProvider::new("/m/zh.onnx", 200, 80).name(), "piper"); - } } diff --git a/src/tts/xai.rs b/src/tts/xai.rs index e40a976..27b3add 100644 --- a/src/tts/xai.rs +++ b/src/tts/xai.rs @@ -167,6 +167,7 @@ mod tests { assert_eq!(provider.language, "auto"); assert_eq!(provider.volume, 100); assert!(provider.is_available()); + assert!(provider.estimate_cost(1000) > 0.0); } #[test] @@ -188,20 +189,6 @@ mod tests { assert!(!provider.is_available()); } - #[test] - fn test_cost_estimation() { - let provider = - XaiTtsProvider::new("test-api-key".to_string(), "eve".to_string(), None, 100); - - // 1M characters = $15.00 - let cost_1m = provider.estimate_cost(1_000_000); - assert!((cost_1m - 15.0).abs() < 0.001); - - // 100 characters - let cost_100 = provider.estimate_cost(100); - assert!((cost_100 - 0.0015).abs() < 0.00001); - } - #[tokio::test] async fn test_speak_empty_message() { let provider = diff --git a/tests/cli.rs b/tests/cli.rs index 09fedfb..ff6a041 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -223,16 +223,6 @@ stop_tts_provider = "macos" ) } -#[test] -fn test_version() { - let env = TestEnv::new(); - env.cmd() - .arg("--version") - .assert() - .success() - .stdout(predicate::str::contains("sumvox")); -} - #[test] fn test_help() { let env = TestEnv::new(); diff --git a/tests/recommended_config.rs b/tests/recommended_config.rs index f283d72..a4f8e08 100644 --- a/tests/recommended_config.rs +++ b/tests/recommended_config.rs @@ -16,46 +16,3 @@ fn recommended_config_parses_as_toml() { parsed.err() ); } - -/// A Linux user can find out how to enable espeak-ng and piper, including -/// where a piper voice model comes from and how playback happens. -#[test] -fn recommended_config_documents_linux_local_tts() { - for needle in [ - "name = \"espeak\"", - "name = \"piper\"", - ".onnx", - "uv tool install piper-tts", - "paplay", - ] { - assert!( - RECOMMENDED.contains(needle), - "config/recommended.toml should document {needle:?}" - ); - } -} - -/// The new blocks were added, not written over the existing macOS block. -#[test] -fn recommended_config_keeps_macos_block_intact() { - assert!( - RECOMMENDED.contains("rate = 200"), - "the macOS block's 'rate = 200' line must survive" - ); - - let parsed: toml::Value = - toml::from_str(RECOMMENDED).expect("config/recommended.toml must stay parseable"); - let providers = parsed["tts"]["providers"] - .as_array() - .expect("[[tts.providers]] must be an array"); - let macos = providers - .iter() - .find(|p| p.get("name").and_then(toml::Value::as_str) == Some("macos")) - .expect("the active macOS TTS provider block must still be present"); - - assert_eq!( - macos.get("rate").and_then(toml::Value::as_integer), - Some(200), - "the macOS provider must still declare rate = 200" - ); -} From 3e3d5fc0a2595c4b022d634b98648daad5864b96 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 11:52:07 +0800 Subject: [PATCH 08/20] test: merge duplicate provider cases into table-driven tests Folds repeated provider availability, engine alias, arg-builder, validation and transcript cases into their surviving tests as table rows, and drops e2e and unit tests that only repeated another test's path. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/audio/file.rs | 31 +----- src/audio/player.rs | 117 +++++---------------- src/audio/wav_header.rs | 81 ++++----------- src/config.rs | 166 +++++++++--------------------- src/hooks/claude_code.rs | 62 ++++-------- src/hooks/mod.rs | 38 +++---- src/llm/anthropic.rs | 48 +++------ src/llm/gemini.rs | 77 +++++--------- src/llm/ollama.rs | 30 ++---- src/llm/openai.rs | 80 +++++---------- src/queue.rs | 14 --- src/transcript.rs | 64 ++---------- src/tts/cloud_tts.rs | 35 ++----- src/tts/elevenlabs.rs | 46 ++++----- src/tts/espeak.rs | 60 +++-------- src/tts/macos.rs | 8 +- src/tts/mod.rs | 212 ++++++++------------------------------- src/tts/piper.rs | 46 +++------ src/tts/xai.rs | 11 +- tests/cli.rs | 79 +-------------- tests/e2e.rs | 51 ---------- 21 files changed, 310 insertions(+), 1046 deletions(-) diff --git a/src/audio/file.rs b/src/audio/file.rs index 79e0b88..ee035db 100644 --- a/src/audio/file.rs +++ b/src/audio/file.rs @@ -157,6 +157,8 @@ mod tests { assert_eq!(provider.volume, 80); assert!(matches!(provider.mode, AudioFileMode::SingleFile(_))); assert_eq!(provider.estimate_cost(100), 0.0); + assert!(provider.is_available()); + assert_eq!(provider.get_playback_file().unwrap(), temp_file.path()); } #[test] @@ -167,6 +169,7 @@ mod tests { let provider = AudioFileProvider::new(path, 100).unwrap(); assert_eq!(provider.name(), "audio_file"); assert!(matches!(provider.mode, AudioFileMode::Directory(_))); + assert!(provider.is_available()); } #[test] @@ -177,17 +180,6 @@ mod tests { assert!(result.unwrap_err().to_string().contains("does not exist")); } - #[test] - fn test_get_playback_file_single_mode() { - let mut temp_file = NamedTempFile::new().unwrap(); - temp_file.write_all(b"fake audio data").unwrap(); - let path = temp_file.path().to_path_buf(); - - let provider = AudioFileProvider::new(path.clone(), 100).unwrap(); - let result = provider.get_playback_file().unwrap(); - assert_eq!(result, path); - } - #[test] fn test_get_playback_file_directory_mode() { let temp_dir = tempdir().unwrap(); @@ -225,21 +217,4 @@ mod tests { .to_string() .contains("No audio files found")); } - - #[test] - fn test_is_available_single_file() { - let mut temp_file = NamedTempFile::new().unwrap(); - temp_file.write_all(b"fake audio").unwrap(); - let path = temp_file.path().to_path_buf(); - - let provider = AudioFileProvider::new(path, 100).unwrap(); - assert!(provider.is_available()); - } - - #[test] - fn test_is_available_directory() { - let temp_dir = tempdir().unwrap(); - let provider = AudioFileProvider::new(temp_dir.path().to_path_buf(), 100).unwrap(); - assert!(provider.is_available()); - } } diff --git a/src/audio/player.rs b/src/audio/player.rs index d73159d..450b068 100644 --- a/src/audio/player.rs +++ b/src/audio/player.rs @@ -394,16 +394,12 @@ mod tests { assert!(found.is_absolute(), "expected absolute path, got {found:?}"); } - #[test] - fn test_find_on_path_empty_name() { - assert!(find_on_path("").is_none()); - } - #[test] fn test_find_on_path_rejects_path_separator() { // A name with a separator is not a PATH lookup — reject rather than // silently resolving it relative to the cwd. assert!(find_on_path("./sh").is_none()); + assert!(find_on_path("").is_none()); } fn args(player: &str, volume: u32) -> Vec { @@ -412,25 +408,17 @@ mod tests { #[test] fn test_player_args_paplay_scales_to_65536() { - assert_eq!(args("paplay", 50), ["--volume=52016", "/tmp/a.wav"]); - assert_eq!(args("paplay", 100), ["--volume=65536", "/tmp/a.wav"]); - } - - #[test] - fn test_player_args_paplay_is_cube_root_not_linear() { - // Second curve point: a linear (or any other monotone) map cannot also - // land here. 41285 = 65536 * 0.25^(1/3), measured -11.8 dB. - assert_eq!(args("paplay", 25), ["--volume=41285", "/tmp/a.wav"]); - } - - #[test] - fn test_player_args_clamps_volume_above_100() { - assert_eq!(args("paplay", 150), ["--volume=65536", "/tmp/a.wav"]); - } - - #[test] - fn test_player_args_paplay_zero_volume() { - assert_eq!(args("paplay", 0), ["--volume=0", "/tmp/a.wav"]); + // (volume, expected --volume). 25 is a second curve point (65536 * 0.25^(1/3), + // measured -11.8 dB) that a linear map cannot also hit; 150 clamps to 100. + for (volume, expected) in [ + (0, "--volume=0"), + (25, "--volume=41285"), + (50, "--volume=52016"), + (100, "--volume=65536"), + (150, "--volume=65536"), + ] { + assert_eq!(args("paplay", volume), [expected, "/tmp/a.wav"]); + } } #[test] @@ -457,31 +445,19 @@ mod tests { #[test] fn test_player_args_mpv() { - assert_eq!( - args("mpv", 50), - [ - "--no-video", - "--no-config", - "--really-quiet", - "--volume=79", - "/tmp/a.wav" - ] - ); - } - - #[test] - fn test_player_args_mpv_is_cube_root_not_linear() { - // Second curve point: 63 = 100 * 0.25^(1/3), measured -11.9 dB. - assert_eq!( - args("mpv", 25), - [ - "--no-video", - "--no-config", - "--really-quiet", - "--volume=63", - "/tmp/a.wav" - ] - ); + // 25 is a second curve point: 100 * 0.25^(1/3), measured -11.9 dB. + for (volume, expected) in [(50, "--volume=79"), (25, "--volume=63")] { + assert_eq!( + args("mpv", volume), + [ + "--no-video", + "--no-config", + "--really-quiet", + expected, + "/tmp/a.wav" + ] + ); + } } #[test] @@ -509,11 +485,6 @@ mod tests { assert_eq!(select_player(&["sumvox_no_such_player"], 100), None); } - #[test] - fn test_select_player_finds_installed() { - assert_eq!(select_player(&["sh"], 100), Some("sh".to_string())); - } - #[test] fn test_select_player_first_installed_wins() { assert_eq!( @@ -577,14 +548,6 @@ mod tests { assert_eq!(runtime_dir_default(None, 1000, false), None); } - #[test] - fn test_runtime_dir_default_handles_root() { - assert_eq!( - runtime_dir_default(None, 0, true), - Some("/run/user/0".to_string()) - ); - } - // Playback tests inject `true`/`false` as the "player": they exercise the // real spawn/wait/exit-code path with no audio device and no real player. const FIVE_SECONDS: Duration = Duration::from_secs(5); @@ -630,18 +593,6 @@ mod tests { ); } - #[test] - fn test_play_file_with_refuses_aplay_at_zero_volume() { - // Deterministic with or without aplay installed. - let err = play_file_with(&["aplay"], Path::new("/tmp/x.wav"), 0, FIVE_SECONDS) - .expect_err("aplay cannot honour volume 0, so this must fail loudly") - .to_string(); - assert!( - err.contains("no supported audio player found"), - "unexpected: {err}" - ); - } - /// A minimal valid WAV file for testing. fn create_test_wav() -> Vec { crate::audio::wav_header::create_wav_file(&[0x00, 0x00], 24000, 1, 16) @@ -682,24 +633,6 @@ mod tests { assert!(result.is_ok()); } - #[test] - #[cfg(target_os = "macos")] - #[ignore = "e2e-audio"] - fn test_play_bytes_zero_volume() { - let wav_data = create_test_wav(); - let result = play_bytes(&wav_data, 0, "sumvox_test_zero"); - assert!(result.is_ok()); - } - - #[test] - #[cfg(target_os = "macos")] - #[ignore = "e2e-audio"] - fn test_play_bytes_max_volume() { - let wav_data = create_test_wav(); - let result = play_bytes(&wav_data, 100, "sumvox_test_max"); - assert!(result.is_ok()); - } - #[test] fn test_play_file_with_kills_a_wedged_player() { // `cat /dev/zero` never exits and never closes stderr, so it wedges the diff --git a/src/audio/wav_header.rs b/src/audio/wav_header.rs index e7c2408..8021433 100644 --- a/src/audio/wav_header.rs +++ b/src/audio/wav_header.rs @@ -81,39 +81,21 @@ mod tests { use super::*; #[test] - fn test_wav_header_24khz_mono_16bit() { - let header = create_wav_header(24000, 1, 16); - assert_eq!(header.len(), 44); - assert_eq!(&header[0..4], b"RIFF"); - assert_eq!(&header[8..12], b"WAVE"); - assert_eq!(&header[22..24], &1u16.to_le_bytes()); // mono - assert_eq!(&header[24..28], &24000u32.to_le_bytes()); // sample rate - assert_eq!(&header[34..36], &16u16.to_le_bytes()); // bits per sample - } - - #[test] - fn test_wav_header_44khz_stereo_16bit() { - let header = create_wav_header(44100, 2, 16); - assert_eq!(header.len(), 44); - assert_eq!(&header[22..24], &2u16.to_le_bytes()); // stereo - assert_eq!(&header[24..28], &44100u32.to_le_bytes()); // sample rate - assert_eq!(&header[34..36], &16u16.to_le_bytes()); // bits per sample - } - - #[test] - fn test_wav_header_byte_rate_calculation() { - // 24000 Hz, mono, 16-bit = 24000 * 1 * 16/8 = 48000 bytes/sec - let header = create_wav_header(24000, 1, 16); - let byte_rate = u32::from_le_bytes([header[28], header[29], header[30], header[31]]); - assert_eq!(byte_rate, 48000); - } - - #[test] - fn test_wav_header_block_align_calculation() { - // stereo, 16-bit = 2 * 16/8 = 4 bytes per sample block - let header = create_wav_header(44100, 2, 16); - let block_align = u16::from_le_bytes([header[32], header[33]]); - assert_eq!(block_align, 4); + fn test_wav_header_fields() { + // (sample_rate, channels, byte_rate, block_align) + for (rate, channels, byte_rate, block_align) in + [(24000u32, 1u16, 48000u32, 2u16), (44100, 2, 176400, 4)] + { + let header = create_wav_header(rate, channels, 16); + assert_eq!(header.len(), 44); + assert_eq!(&header[0..4], b"RIFF"); + assert_eq!(&header[8..12], b"WAVE"); + assert_eq!(&header[22..24], &channels.to_le_bytes()); + assert_eq!(&header[24..28], &rate.to_le_bytes()); + assert_eq!(&header[28..32], &byte_rate.to_le_bytes()); + assert_eq!(&header[32..34], &block_align.to_le_bytes()); + assert_eq!(&header[34..36], &16u16.to_le_bytes()); + } } #[test] @@ -123,32 +105,13 @@ mod tests { assert_eq!(wav.len(), 48); // 44 header + 4 data assert_eq!(&wav[0..4], b"RIFF"); assert_eq!(&wav[44..48], &pcm); - } - #[test] - fn test_wav_file_riff_size() { - let pcm = vec![0x00; 1000]; - let wav = create_wav_file(&pcm, 24000, 1, 16); - // RIFF size at bytes 4-7 should be 36 + 1000 = 1036 - let riff_size = u32::from_le_bytes([wav[4], wav[5], wav[6], wav[7]]); - assert_eq!(riff_size, 1036); - } - - #[test] - fn test_wav_file_data_size() { - let pcm = vec![0x00; 1000]; - let wav = create_wav_file(&pcm, 24000, 1, 16); - // Data size at bytes 40-43 should be 1000 - let data_size = u32::from_le_bytes([wav[40], wav[41], wav[42], wav[43]]); - assert_eq!(data_size, 1000); - } - - #[test] - fn test_wav_file_empty_pcm() { - let pcm: Vec = vec![]; - let wav = create_wav_file(&pcm, 24000, 1, 16); - assert_eq!(wav.len(), 44); // header only - let data_size = u32::from_le_bytes([wav[40], wav[41], wav[42], wav[43]]); - assert_eq!(data_size, 0); + // (pcm length, expected RIFF size at 4..8 = 36 + data, data size at 40..44) + for (len, riff_size) in [(0usize, 36u32), (1000, 1036)] { + let wav = create_wav_file(&vec![0u8; len], 24000, 1, 16); + assert_eq!(wav.len(), 44 + len); + assert_eq!(&wav[4..8], &riff_size.to_le_bytes()); + assert_eq!(&wav[40..44], &(len as u32).to_le_bytes()); + } } } diff --git a/src/config.rs b/src/config.rs index 533ca39..b67fa66 100644 --- a/src/config.rs +++ b/src/config.rs @@ -845,13 +845,13 @@ mod tests { fn test_get_openai_api_key_from_config() { let provider = openai_tts_provider(Some("sk-test".to_string())); assert_eq!(provider.get_openai_api_key(), Some("sk-test".to_string())); - } - #[test] - fn test_get_openai_api_key_placeholder_env_unset() { - std::env::remove_var("OPENAI_API_KEY"); - let provider = openai_tts_provider(Some("${OPENAI_API_KEY}".to_string())); - assert_eq!(provider.get_openai_api_key(), None); + // A `${...}` placeholder is never returned as the key itself + let placeholder = openai_tts_provider(Some("${OPENAI_API_KEY}".to_string())); + assert_ne!( + placeholder.get_openai_api_key(), + Some("${OPENAI_API_KEY}".to_string()) + ); } #[test] @@ -893,51 +893,38 @@ mod tests { #[test] fn test_validate_invalid_temperature() { - let mut config = SumvoxConfig::default(); - config.llm.parameters.temperature = 3.0; - - let result = config.validate(); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Temperature 3 out of range")); - } - - #[test] - fn test_validate_invalid_tts_rate() { - let mut config = SumvoxConfig::default(); - config.tts.providers[1].rate = Some(500); - - let result = config.validate(); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("TTS rate 500 out of range")); - } - - #[test] - fn test_validate_invalid_tts_volume() { - let mut config = SumvoxConfig::default(); - config.tts.providers[0].volume = Some(150); - - let result = config.validate(); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("TTS volume 150 out of range")); - } + type Mutate = fn(&mut SumvoxConfig); + let cases: [(&str, Mutate); 5] = [ + ("Temperature 3 out of range", |c| { + c.llm.parameters.temperature = 3.0 + }), + ("TTS rate 500 out of range", |c| { + c.tts.providers[1].rate = Some(500) + }), + ("TTS volume 150 out of range", |c| { + c.tts.providers[0].volume = Some(150) + }), + ("Notification volume", |c| { + c.hooks.claude_code.notification_volume = Some(150) + }), + ("Stop hook volume", |c| { + c.hooks.claude_code.stop_volume = Some(200) + }), + ]; + for (expected, mutate) in cases { + let mut config = SumvoxConfig::default(); + mutate(&mut config); + let err = config.validate().expect_err(expected); + assert!(err.to_string().contains(expected), "{expected}: {err}"); + } - #[test] - fn test_validate_valid_tts_volume() { + // Boundary values are accepted let mut config = SumvoxConfig::default(); config.tts.providers[0].volume = Some(75); config.tts.providers[1].volume = Some(100); - - let result = config.validate(); - assert!(result.is_ok()); + config.hooks.claude_code.notification_volume = Some(80); + config.hooks.claude_code.stop_volume = Some(100); + assert!(config.validate().is_ok()); } #[test] @@ -1039,33 +1026,6 @@ tts: .starts_with("config.yaml.backup-")); } - #[test] - fn test_hook_volume_validation() { - let mut config = SumvoxConfig::default(); - config.hooks.claude_code.notification_volume = Some(80); - config.hooks.claude_code.stop_volume = Some(100); - assert!(config.validate().is_ok()); - - // Invalid notification volume - config.hooks.claude_code.notification_volume = Some(150); - assert!(config.validate().is_err()); - assert!(config - .validate() - .unwrap_err() - .to_string() - .contains("Notification volume")); - - // Reset and test invalid stop volume - config.hooks.claude_code.notification_volume = Some(80); - config.hooks.claude_code.stop_volume = Some(200); - assert!(config.validate().is_err()); - assert!(config - .validate() - .unwrap_err() - .to_string() - .contains("Stop hook volume")); - } - #[test] fn test_service_account_key_reads_file() { use std::io::Write; @@ -1095,37 +1055,16 @@ tts: let content = config.get_service_account_key(); assert!(content.is_some()); assert_eq!(content.unwrap(), json_content); - } - #[test] - fn test_service_account_key_none() { - let config = TtsProviderConfig { - name: "cloud_tts".to_string(), - model: None, - voice: None, - api_key: None, - rate: None, - volume: None, - path: None, + let no_key = TtsProviderConfig { service_account_key: None, - language_code: None, - speed: None, - stability: None, - style: None, - style_prompt: None, + ..config }; - - assert_eq!(config.get_service_account_key(), None); + assert_eq!(no_key.get_service_account_key(), None); } // ── ContentSource tests ────────────────────────────────────────── - #[test] - fn test_content_source_default() { - let config = SummarizationConfig::default(); - assert_eq!(config.content_source, ContentSource::Transcript); - } - #[test] fn test_content_source_serde_round_trip() { let toml_absent = r#" @@ -1195,27 +1134,18 @@ turns = 1 let provider = make_provider(None); let params = make_params(false); assert!(!effective_disable_thinking(&provider, ¶ms)); - } - - #[test] - fn test_c1_provider_none_uses_global_true() { - let provider = make_provider(None); - let params = make_params(true); - assert!(effective_disable_thinking(&provider, ¶ms)); - } - #[test] - fn test_c1_provider_some_true_overrides_global_false() { - let provider = make_provider(Some(true)); - let params = make_params(false); - assert!(effective_disable_thinking(&provider, ¶ms)); - } - - #[test] - fn test_c1_provider_some_false_overrides_global_true() { - let provider = make_provider(Some(false)); - let params = make_params(true); - assert!(!effective_disable_thinking(&provider, ¶ms)); + // (provider override, global, expected): the provider wins when set + for (over, global, expected) in [ + (None, true, true), + (Some(true), false, true), + (Some(false), true, false), + ] { + assert_eq!( + effective_disable_thinking(&make_provider(over), &make_params(global)), + expected + ); + } } // ── C6: per-provider disable_thinking TOML deserialization ────────── diff --git a/src/hooks/claude_code.rs b/src/hooks/claude_code.rs index 68bdf6e..9bb1af8 100644 --- a/src/hooks/claude_code.rs +++ b/src/hooks/claude_code.rs @@ -337,54 +337,30 @@ mod tests { ); } - // ── Volume override tests ────────────────────────────────────────── - - // ── Contract 1: last_assistant_message deserialization ────────────── - - // ── Contract 3: select_stop_context_source logic ──────────────────── - #[test] - fn test_select_source_transcript_none() { - use crate::config::ContentSource; - let source = select_stop_context_source(ContentSource::Transcript, None); - assert!(matches!(source, StopContextSource::ReadTranscript)); - } + fn test_select_source() { + use crate::config::ContentSource::{LastMessage, Transcript}; - #[test] - fn test_select_source_transcript_some() { - use crate::config::ContentSource; - let source = select_stop_context_source(ContentSource::Transcript, Some("anything")); - assert!(matches!(source, StopContextSource::ReadTranscript)); - } - - #[test] - fn test_select_source_last_message_present() { - use crate::config::ContentSource; - let source = select_stop_context_source(ContentSource::LastMessage, Some("hello")); - match source { + // The last message is used only when asked for and non-blank + match select_stop_context_source(LastMessage, Some("hello")) { StopContextSource::UseLastMessage(text) => assert_eq!(text, "hello"), _ => panic!("Expected UseLastMessage"), } - } - #[test] - fn test_select_source_last_message_whitespace() { - use crate::config::ContentSource; - let source = select_stop_context_source(ContentSource::LastMessage, Some(" ")); - assert!(matches!(source, StopContextSource::ReadTranscript)); - } - - #[test] - fn test_select_source_last_message_empty() { - use crate::config::ContentSource; - let source = select_stop_context_source(ContentSource::LastMessage, Some("")); - assert!(matches!(source, StopContextSource::ReadTranscript)); - } - - #[test] - fn test_select_source_last_message_none() { - use crate::config::ContentSource; - let source = select_stop_context_source(ContentSource::LastMessage, None); - assert!(matches!(source, StopContextSource::ReadTranscript)); + for (source, message) in [ + (Transcript, None), + (Transcript, Some("anything")), + (LastMessage, Some(" ")), + (LastMessage, Some("")), + (LastMessage, None), + ] { + assert!( + matches!( + select_stop_context_source(source, message), + StopContextSource::ReadTranscript + ), + "{source:?} with {message:?} should read the transcript" + ); + } } } diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 29d5b04..270731b 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -98,6 +98,11 @@ mod tests { }); assert_eq!(detect_format(&json), HookFormat::ClaudeCode); + + let (json, format) = + parse_input(r#"{"session_id": "test", "hook_event_name": "Stop"}"#).unwrap(); + assert_eq!(format, HookFormat::ClaudeCode); + assert_eq!(json["session_id"], "test"); } #[test] @@ -129,23 +134,13 @@ mod tests { #[test] fn test_parse_generic_with_text() { - let input = r#"{"text": "Hello world"}"#; - let generic = parse_generic(input).unwrap(); - assert_eq!(generic.get_text(), Some("Hello world")); - } - - #[test] - fn test_parse_generic_with_message() { - let input = r#"{"message": "Hello from message"}"#; - let generic = parse_generic(input).unwrap(); - assert_eq!(generic.get_text(), Some("Hello from message")); - } - - #[test] - fn test_parse_generic_with_content() { - let input = r#"{"content": "Hello from content"}"#; - let generic = parse_generic(input).unwrap(); - assert_eq!(generic.get_text(), Some("Hello from content")); + for (input, expected) in [ + (r#"{"text": "Hello world"}"#, "Hello world"), + (r#"{"message": "Hello from message"}"#, "Hello from message"), + (r#"{"content": "Hello from content"}"#, "Hello from content"), + ] { + assert_eq!(parse_generic(input).unwrap().get_text(), Some(expected)); + } } #[test] @@ -154,13 +149,4 @@ mod tests { let result = parse_generic(input); assert!(result.is_err()); } - - #[test] - fn test_parse_input() { - let input = r#"{"session_id": "test", "hook_event_name": "Stop"}"#; - let (json, format) = parse_input(input).unwrap(); - - assert_eq!(format, HookFormat::ClaudeCode); - assert_eq!(json["session_id"], "test"); - } } diff --git a/src/llm/anthropic.rs b/src/llm/anthropic.rs index c51a133..888c27d 100644 --- a/src/llm/anthropic.rs +++ b/src/llm/anthropic.rs @@ -192,39 +192,21 @@ mod tests { #[test] fn test_is_available_with_key() { - let provider = AnthropicProvider::with_base_url( - "sk-ant-test-key".to_string(), - "claude-haiku-4-5".to_string(), - ANTHROPIC_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert_eq!(provider.name(), "anthropic"); - assert!(provider.is_available()); - } - - #[test] - fn test_is_available_without_key() { - let provider = AnthropicProvider::with_base_url( - "".to_string(), - "claude-haiku-4-5".to_string(), - ANTHROPIC_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert!(!provider.is_available()); - } - - #[test] - fn test_is_available_with_placeholder() { - let provider = AnthropicProvider::with_base_url( - "${ANTHROPIC_API_KEY}".to_string(), - "claude-haiku-4-5".to_string(), - ANTHROPIC_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert!(!provider.is_available()); + // (api key, expected): empty and `${...}` placeholder keys are unusable + for (key, expected) in [ + ("sk-ant-test-key", true), + ("", false), + ("${ANTHROPIC_API_KEY}", false), + ] { + let provider = AnthropicProvider::with_base_url( + key.to_string(), + "claude-haiku-4-5".to_string(), + ANTHROPIC_API_BASE.to_string(), + Duration::from_secs(10), + ); + assert_eq!(provider.is_available(), expected, "key={key:?}"); + assert_eq!(provider.name(), "anthropic"); + } } #[tokio::test] diff --git a/src/llm/gemini.rs b/src/llm/gemini.rs index cb3876f..16a936b 100644 --- a/src/llm/gemini.rs +++ b/src/llm/gemini.rs @@ -240,65 +240,36 @@ impl LlmProvider for GeminiProvider { mod tests { use super::*; - #[test] - fn test_gemini_provider_creation() { - let provider = GeminiProvider::with_base_url( - "test-key".to_string(), - "gemini/gemini-2.0-flash-exp".to_string(), - GEMINI_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert_eq!(provider.name(), "gemini"); - assert!(provider.is_available()); - } - #[test] fn test_is_available_with_empty_key() { - let provider = GeminiProvider::with_base_url( - "".to_string(), - "gemini/gemini-2.0-flash-exp".to_string(), - GEMINI_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert!(!provider.is_available()); - } - - #[test] - fn test_is_available_with_env_var_placeholder() { - let provider = GeminiProvider::with_base_url( - "${GEMINI_API_KEY}".to_string(), - "gemini/gemini-2.0-flash-exp".to_string(), - GEMINI_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert!(!provider.is_available()); + // (api key, expected): empty and `${...}` placeholder keys are unusable + for (key, expected) in [ + ("test-key", true), + ("", false), + ("${GEMINI_API_KEY}", false), + ] { + let provider = GeminiProvider::with_base_url( + key.to_string(), + "gemini/gemini-2.0-flash-exp".to_string(), + GEMINI_API_BASE.to_string(), + Duration::from_secs(10), + ); + assert_eq!(provider.is_available(), expected, "key={key:?}"); + assert_eq!(provider.name(), "gemini"); + } } #[test] fn test_extract_model_name() { - let provider = GeminiProvider::with_base_url( - "test-key".to_string(), - "gemini/gemini-2.0-flash-exp".to_string(), - GEMINI_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert_eq!(provider.extract_model_name(), "gemini-2.0-flash-exp"); - } - - #[test] - fn test_extract_model_name_without_prefix() { - let provider = GeminiProvider::with_base_url( - "test-key".to_string(), - "gemini-2.0-flash-exp".to_string(), - GEMINI_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert_eq!(provider.extract_model_name(), "gemini-2.0-flash-exp"); + for model in ["gemini/gemini-2.0-flash-exp", "gemini-2.0-flash-exp"] { + let provider = GeminiProvider::with_base_url( + "test-key".to_string(), + model.to_string(), + GEMINI_API_BASE.to_string(), + Duration::from_secs(10), + ); + assert_eq!(provider.extract_model_name(), "gemini-2.0-flash-exp"); + } } #[tokio::test] diff --git a/src/llm/ollama.rs b/src/llm/ollama.rs index ffbafee..6e9b4b6 100644 --- a/src/llm/ollama.rs +++ b/src/llm/ollama.rs @@ -144,29 +144,17 @@ mod tests { #[test] fn test_extract_model_name() { - let provider = OllamaProvider::with_base_url( - "http://localhost:11434".to_string(), - "ollama/llama3.1".to_string(), - Duration::from_secs(30), - ); - - assert_eq!(provider.extract_model_name(), "llama3.1"); - assert!(provider.is_available()); - } - - #[test] - fn test_extract_model_name_without_prefix() { - let provider = OllamaProvider::with_base_url( - "http://localhost:11434".to_string(), - "llama3.1".to_string(), - Duration::from_secs(30), - ); - - assert_eq!(provider.extract_model_name(), "llama3.1"); + for model in ["ollama/llama3.1", "llama3.1"] { + let provider = OllamaProvider::with_base_url( + "http://localhost:11434".to_string(), + model.to_string(), + Duration::from_secs(30), + ); + assert_eq!(provider.extract_model_name(), "llama3.1"); + assert!(provider.is_available()); + } } - // ── C2: OllamaRequestSerialization ────────────────────────────────── - #[test] fn test_c2_think_wire_format() { let request = |think| OllamaRequest { diff --git a/src/llm/openai.rs b/src/llm/openai.rs index 007ab65..0e1b7e2 100644 --- a/src/llm/openai.rs +++ b/src/llm/openai.rs @@ -227,63 +227,37 @@ mod tests { #[test] fn test_is_available_with_key() { - let provider = OpenAIProvider::with_base_url( - "sk-test-key".to_string(), - "gpt-4o-mini".to_string(), - OPENAI_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert_eq!(provider.name(), "openai"); - assert!(provider.is_available()); - } - - #[test] - fn test_is_available_without_key() { - let provider = OpenAIProvider::with_base_url( - "".to_string(), - "gpt-4o-mini".to_string(), - OPENAI_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert!(!provider.is_available()); - } - - #[test] - fn test_is_available_with_placeholder() { - let provider = OpenAIProvider::with_base_url( - "${OPENAI_API_KEY}".to_string(), - "gpt-4o-mini".to_string(), - OPENAI_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert!(!provider.is_available()); + // (api key, expected): empty and `${...}` placeholder keys are unusable + for (key, expected) in [ + ("sk-test-key", true), + ("", false), + ("${OPENAI_API_KEY}", false), + ] { + let provider = OpenAIProvider::with_base_url( + key.to_string(), + "gpt-4o-mini".to_string(), + OPENAI_API_BASE.to_string(), + Duration::from_secs(10), + ); + assert_eq!(provider.is_available(), expected, "key={key:?}"); + assert_eq!(provider.name(), "openai"); + } } #[test] fn test_extract_model_name() { - let provider = OpenAIProvider::with_base_url( - "test-key".to_string(), - "openai/gpt-4o-mini".to_string(), - OPENAI_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert_eq!(provider.extract_model_name(), "gpt-4o-mini"); - } - - #[test] - fn test_extract_model_name_without_prefix() { - let provider = OpenAIProvider::with_base_url( - "test-key".to_string(), - "gpt-4o-mini".to_string(), - OPENAI_API_BASE.to_string(), - Duration::from_secs(10), - ); - - assert_eq!(provider.extract_model_name(), "gpt-4o-mini"); + for (model, expected) in [ + ("openai/gpt-4o-mini", "gpt-4o-mini"), + ("gpt-4o-mini", "gpt-4o-mini"), + ] { + let provider = OpenAIProvider::with_base_url( + "test-key".to_string(), + model.to_string(), + OPENAI_API_BASE.to_string(), + Duration::from_secs(10), + ); + assert_eq!(provider.extract_model_name(), expected); + } } #[tokio::test] diff --git a/src/queue.rs b/src/queue.rs index 7ed0a6f..36a247d 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -127,20 +127,6 @@ mod tests { use super::*; use tempfile::tempdir; - #[tokio::test] - async fn test_lock_acquire_when_available() { - let temp_dir = tempdir().unwrap(); - let lock_path = temp_dir.path().join("test.lock"); - - // Create queue with custom lock path - let mut queue = NotificationQueue::new(Some(Duration::from_secs(5))).unwrap(); - queue.lock_file_path = lock_path; - - // Should acquire lock successfully - let lock = QueueLock::acquire(&queue).await; - assert!(lock.is_ok()); - } - #[tokio::test] async fn test_lock_auto_release_on_drop() { let temp_dir = tempdir().unwrap(); diff --git a/src/transcript.rs b/src/transcript.rs index 3a2819f..2c7ccdc 100644 --- a/src/transcript.rs +++ b/src/transcript.rs @@ -270,47 +270,12 @@ mod tests { assert_eq!(texts[0], "First response"); assert_eq!(texts[1], "Second response"); assert_eq!(texts[2], "Third response"); - } - - #[tokio::test] - async fn test_read_last_n_texts() { - let jsonl_content = r#"{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Text 1"}]},"timestamp":"2025-01-22T10:00:01Z"} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Text 2"}]},"timestamp":"2025-01-22T10:00:02Z"} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Text 3"}]},"timestamp":"2025-01-22T10:00:03Z"} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Text 4"}]},"timestamp":"2025-01-22T10:00:04Z"} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Text 5"}]},"timestamp":"2025-01-22T10:00:05Z"} -"#; - - let mut temp_file = NamedTempFile::new().unwrap(); - temp_file.write_all(jsonl_content.as_bytes()).unwrap(); - let path = temp_file.path(); - - let texts = TranscriptReader::read_last_n_texts(path, 3).await.unwrap(); - - assert_eq!(texts.len(), 3); - assert_eq!(texts[0], "Text 3"); - assert_eq!(texts[1], "Text 4"); - assert_eq!(texts[2], "Text 5"); - } - - #[tokio::test] - async fn test_read_with_limit() { - let jsonl_content = r#"{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Text 1"}]}} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Text 2"}]}} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Text 3"}]}} -"#; - let mut temp_file = NamedTempFile::new().unwrap(); - temp_file.write_all(jsonl_content.as_bytes()).unwrap(); - let path = temp_file.path(); - - let texts = TranscriptReader::read_assistant_texts(path, 2) + // The limit stops reading early + let limited = TranscriptReader::read_assistant_texts(path, 2) .await .unwrap(); - - assert_eq!(texts.len(), 2); - assert_eq!(texts[0], "Text 1"); - assert_eq!(texts[1], "Text 2"); + assert_eq!(limited, ["First response", "Second response"]); } #[tokio::test] @@ -397,26 +362,6 @@ invalid json line assert_eq!(texts[2], "Response 3"); } - #[tokio::test] - async fn test_read_last_n_turns_with_tools() { - let jsonl_content = r#"{"type":"message","message":{"role":"user","content":[{"type":"text","text":"Do something"}]}} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Let me help"}]}} -{"type":"message","message":{"role":"assistant","content":[{"type":"tool_use","name":"bash","input":{"command":"ls"}}]}} -{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Done"}]}} -"#; - - let mut temp_file = NamedTempFile::new().unwrap(); - temp_file.write_all(jsonl_content.as_bytes()).unwrap(); - let path = temp_file.path(); - - let texts = TranscriptReader::read_last_n_turns(path, 1).await.unwrap(); - - // Should only extract text blocks, not tool_use - assert_eq!(texts.len(), 2); - assert_eq!(texts[0], "Let me help"); - assert_eq!(texts[1], "Done"); - } - #[tokio::test] async fn test_read_last_n_turns_no_user() { let jsonl_content = r#"{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Text 1"}]}} @@ -432,6 +377,9 @@ invalid json line let texts = TranscriptReader::read_last_n_turns(path, 1).await.unwrap(); assert_eq!(texts.len(), 1); assert_eq!(texts[0], "Text 3"); + + let texts = TranscriptReader::read_last_n_texts(path, 2).await.unwrap(); + assert_eq!(texts, ["Text 2", "Text 3"]); } #[tokio::test] diff --git a/src/tts/cloud_tts.rs b/src/tts/cloud_tts.rs index 9fa5b80..9bd423e 100644 --- a/src/tts/cloud_tts.rs +++ b/src/tts/cloud_tts.rs @@ -298,11 +298,8 @@ mod tests { let p = create_test_provider(); assert_eq!(p.name(), "cloud_tts"); assert!(p.is_available()); - } - #[test] - fn test_is_available_empty() { - let p = CloudTtsProvider::new( + let empty = CloudTtsProvider::new( String::new(), "en-US-Standard-A".to_string(), None, @@ -310,7 +307,7 @@ mod tests { None, 100, ); - assert!(!p.is_available()); + assert!(!empty.is_available()); } #[tokio::test] @@ -319,13 +316,6 @@ mod tests { assert!(!p.speak("").await.unwrap()); } - #[test] - fn test_cost_estimation() { - let p = create_test_provider(); - let cost = p.estimate_cost(1_000_000); - assert!((cost - 4.0).abs() < 0.01); - } - #[test] fn test_language_defaults_to_en_us_when_absent() { // voice is required and used verbatim; language_code is a neutral tuning @@ -407,15 +397,9 @@ mod tests { } #[test] - fn test_gemini_chunk_cap_is_4000() { - let p = create_gemini_provider(); - assert_eq!(p.max_chunk_bytes(), 4000); - } - - #[test] - fn test_traditional_chunk_cap_is_5000() { - let p = create_test_provider(); - assert_eq!(p.max_chunk_bytes(), 5000); + fn test_chunk_cap_by_model() { + assert_eq!(create_gemini_provider().max_chunk_bytes(), 4000); + assert_eq!(create_test_provider().max_chunk_bytes(), 5000); } #[test] @@ -424,12 +408,9 @@ mod tests { let cost = p.estimate_cost(1_000_000); // Coarse per-char proxy for token billing (~$16.7 / 1M chars). assert!((cost - 16.7).abs() < 0.1, "unexpected gemini cost: {cost}"); - } - #[test] - fn test_traditional_cost_unchanged() { - let p = create_test_provider(); - let cost = p.estimate_cost(1_000_000); - assert!((cost - 4.0).abs() < 0.01); + // Non-Gemini voices keep the flat per-char price + let traditional = create_test_provider().estimate_cost(1_000_000); + assert!((traditional - 4.0).abs() < 0.01); } } diff --git a/src/tts/elevenlabs.rs b/src/tts/elevenlabs.rs index 2f4a146..d31aed0 100644 --- a/src/tts/elevenlabs.rs +++ b/src/tts/elevenlabs.rs @@ -278,35 +278,23 @@ mod tests { #[test] fn test_cost_estimation_flash() { - let provider = ElevenLabsProvider::new( - "test-key".to_string(), - "21m00Tcm4TlvDq8ikWAM".to_string(), - "eleven_flash_v2_5".to_string(), - None, - None, - None, - 100, - ); - assert_eq!(provider.name(), "elevenlabs"); - // 1M chars × $0.00005 = $50 - let cost = provider.estimate_cost(1_000_000); - assert!((cost - 50.0).abs() < 0.001); - } - - #[test] - fn test_cost_estimation_multilingual() { - let provider = ElevenLabsProvider::new( - "test-key".to_string(), - "21m00Tcm4TlvDq8ikWAM".to_string(), - "eleven_multilingual_v2".to_string(), - None, - None, - None, - 100, - ); - // 1M chars × $0.0001 = $100 - let cost = provider.estimate_cost(1_000_000); - assert!((cost - 100.0).abs() < 0.001); + // (model, expected cost for 1M chars): flash is half the multilingual price + for (model, expected) in [ + ("eleven_flash_v2_5", 50.0), + ("eleven_multilingual_v2", 100.0), + ] { + let provider = ElevenLabsProvider::new( + "test-key".to_string(), + "21m00Tcm4TlvDq8ikWAM".to_string(), + model.to_string(), + None, + None, + None, + 100, + ); + assert_eq!(provider.name(), "elevenlabs"); + assert!((provider.estimate_cost(1_000_000) - expected).abs() < 0.001); + } } #[tokio::test] diff --git a/src/tts/espeak.rs b/src/tts/espeak.rs index e289d59..ddc36cb 100644 --- a/src/tts/espeak.rs +++ b/src/tts/espeak.rs @@ -230,40 +230,23 @@ mod tests { args(Some("cmn+f3"), 200), ["--stdin", "-w", "/tmp/o.wav", "-v", "cmn+f3", "-s", "200"] ); - } - - #[test] - fn test_espeak_args_omits_voice_when_absent() { - assert_eq!( - args(None, 175), - ["--stdin", "-w", "/tmp/o.wav", "-s", "175"] - ); - } - - #[test] - fn test_espeak_args_omits_blank_voice() { - assert_eq!( - args(Some(" "), 175), - ["--stdin", "-w", "/tmp/o.wav", "-s", "175"] - ); - } - #[test] - fn test_espeak_args_clamps_rate_to_minimum() { - let argv = args(Some("cmn"), 10); - assert!( - argv.windows(2).any(|w| w == ["-s", "80"]), - "expected clamp to 80: {argv:?}" - ); - } + // Absent and blank voices are omitted + for voice in [None, Some(" ")] { + assert_eq!( + args(voice, 175), + ["--stdin", "-w", "/tmp/o.wav", "-s", "175"] + ); + } - #[test] - fn test_espeak_args_clamps_rate_to_maximum() { - let argv = args(Some("cmn"), 10000); - assert!( - argv.windows(2).any(|w| w == ["-s", "450"]), - "expected clamp to 450: {argv:?}" - ); + // Rate is clamped to espeak-ng's 80..=450 range + for (rate, clamped) in [(10, "80"), (10000, "450")] { + let argv = args(Some("cmn"), rate); + assert!( + argv.windows(2).any(|w| w == ["-s", clamped]), + "expected clamp to {clamped}: {argv:?}" + ); + } } // Synthesis tests inject `true`/`false` as the "engine": they exercise the @@ -315,11 +298,6 @@ mod tests { // so Ok(false) proves the guard runs before the spawn. let provider = EspeakProvider::new(None, 175, 80).with_binary("false"); assert!(!provider.speak("").await.expect("empty text must not error")); - } - - #[tokio::test] - async fn test_speak_whitespace_only_is_a_no_op() { - let provider = EspeakProvider::new(None, 175, 80).with_binary("false"); assert!(!provider .speak(" \n ") .await @@ -334,12 +312,4 @@ mod tests { "a missing engine must be skipped by the chain, not error" ); } - - #[test] - fn test_espeak_args_never_carries_the_text() { - // The builder has no text parameter at all — a structural guarantee, - // stronger than asserting the absence of a string. - let argv = args(Some("cmn"), 175); - assert!(argv.contains(&"--stdin".to_string()), "{argv:?}"); - } } diff --git a/src/tts/macos.rs b/src/tts/macos.rs index 76b5ba2..453cdec 100644 --- a/src/tts/macos.rs +++ b/src/tts/macos.rs @@ -119,16 +119,10 @@ mod tests { let provider = MacOsTtsProvider::new(Some("Tingting".to_string()), 200, 100); let result = provider.speak("").await.unwrap(); assert!(!result); + assert!(!provider.speak(" ").await.unwrap()); assert_eq!(provider.estimate_cost(100), 0.0); } - #[tokio::test] - async fn test_speak_whitespace_only() { - let provider = MacOsTtsProvider::new(Some("Tingting".to_string()), 200, 100); - let result = provider.speak(" ").await.unwrap(); - assert!(!result); - } - // Exercises the full render-to-file + playback path at a low volume; fails // if `say -o` or the playback handoff breaks. Volume 1 stays near-silent. #[cfg(target_os = "macos")] diff --git a/src/tts/mod.rs b/src/tts/mod.rs index cbc44b2..c717177 100644 --- a/src/tts/mod.rs +++ b/src/tts/mod.rs @@ -392,16 +392,33 @@ mod tests { #[test] fn test_tts_engine_from_str() { - assert_eq!("macos".parse::().ok(), Some(TtsEngine::MacOS)); - assert_eq!("say".parse::().ok(), Some(TtsEngine::MacOS)); - assert_eq!("google".parse::().ok(), Some(TtsEngine::Google)); - assert_eq!( - "google_tts".parse::().ok(), - Some(TtsEngine::Google) - ); - assert_eq!("gcloud".parse::().ok(), Some(TtsEngine::Google)); - assert_eq!("auto".parse::().ok(), Some(TtsEngine::Auto)); + use TtsEngine::*; + for (tag, engine) in [ + ("macos", MacOS), + ("say", MacOS), + ("google", Google), + ("google_tts", Google), + ("gcloud", Google), + ("cloud_tts", CloudTts), + ("gcp_tts", CloudTts), + ("google_cloud", CloudTts), + ("gemini_tts", CloudTts), + ("openai", OpenAi), + ("openai_tts", OpenAi), + ("espeak", Espeak), + ("espeak-ng", Espeak), + ("espeak_ng", Espeak), + // Parsing is case-insensitive, like every other engine tag + ("ESPEAK", Espeak), + ("piper", Piper), + ("piper_tts", Piper), + ("auto", Auto), + ] { + assert_eq!(tag.parse::().ok(), Some(engine), "{tag}"); + } assert!("unknown".parse::().is_err()); + // Only the three documented spellings map to Espeak + assert!("espeakng".parse::().is_err()); // Display and FromStr agree on the canonical name of every engine for engine in [ @@ -420,26 +437,6 @@ mod tests { } } - #[test] - fn test_cloud_tts_engine_from_str() { - assert_eq!( - "cloud_tts".parse::().ok(), - Some(TtsEngine::CloudTts) - ); - assert_eq!( - "gcp_tts".parse::().ok(), - Some(TtsEngine::CloudTts) - ); - assert_eq!( - "google_cloud".parse::().ok(), - Some(TtsEngine::CloudTts) - ); - assert_eq!( - "gemini_tts".parse::().ok(), - Some(TtsEngine::CloudTts) - ); - } - #[test] fn test_factory_recognizes_gemini_tts_alias() { // gemini_tts routes through the cloud_tts branch; missing service @@ -522,14 +519,6 @@ mod tests { const ESPEAK_ALIASES: &[&str] = &["espeak", "espeak_ng", "espeak-ng"]; const PIPER_ALIASES: &[&str] = &["piper", "piper_tts"]; - #[test] - fn test_dispatch_resolves_espeak_from_config() { - let providers = vec![espeak_config("espeak", Some("cmn+f3"), Some(175))]; - let provider = resolve_tts_provider(&providers, ESPEAK_ALIASES, None, 200, None) - .expect("configured espeak entry should resolve"); - assert_eq!(provider.name(), "espeak"); - } - #[test] fn test_dispatch_espeak_errors_when_absent_from_config() { let providers: Vec = vec![]; @@ -543,59 +532,6 @@ mod tests { ); } - #[test] - fn test_dispatch_resolves_piper_with_cli_voice_override() { - // The CLI `--voice` overlay must reach piper's model knob. - let providers = vec![piper_config(None, Some("/m/a.onnx"), None)]; - let provider = - resolve_tts_provider(&providers, PIPER_ALIASES, Some("/m/b.onnx"), 200, None) - .expect("configured piper entry should resolve"); - assert_eq!(provider.name(), "piper"); - } - - #[test] - fn test_dispatch_piper_errors_when_absent_from_config() { - let providers: Vec = vec![]; - let err = resolve_tts_provider(&providers, PIPER_ALIASES, None, 200, None) - .err() - .expect("an unconfigured engine must error") - .to_string(); - assert!( - err.contains("piper provider not found in config"), - "unexpected error: {err}" - ); - } - - #[test] - fn test_espeak_engine_from_str_accepts_all_aliases() { - assert_eq!("espeak".parse::().ok(), Some(TtsEngine::Espeak)); - assert_eq!( - "espeak-ng".parse::().ok(), - Some(TtsEngine::Espeak) - ); - assert_eq!( - "espeak_ng".parse::().ok(), - Some(TtsEngine::Espeak) - ); - // Parsing is case-insensitive, like every other engine tag. - assert_eq!("ESPEAK".parse::().ok(), Some(TtsEngine::Espeak)); - } - - #[test] - fn test_piper_engine_from_str_accepts_all_aliases() { - assert_eq!("piper".parse::().ok(), Some(TtsEngine::Piper)); - assert_eq!( - "piper_tts".parse::().ok(), - Some(TtsEngine::Piper) - ); - } - - #[test] - fn test_espeakng_is_not_an_alias() { - // Only the three documented spellings map to Espeak. - assert!("espeakng".parse::().is_err()); - } - fn espeak_config(name: &str, voice: Option<&str>, rate: Option) -> TtsProviderConfig { TtsProviderConfig { name: name.to_string(), @@ -614,25 +550,15 @@ mod tests { } } - #[test] - fn test_factory_builds_espeak_from_alias_with_voice_and_rate() { - let provider = create_single_tts(&espeak_config("espeak_ng", Some("cmn+f3"), Some(175))) - .expect("espeak_ng entry should build"); - assert_eq!(provider.name(), "espeak"); - } - #[test] fn test_factory_builds_espeak_with_no_voice_or_rate() { let provider = create_single_tts(&espeak_config("espeak", None, None)) .expect("a bare espeak entry needs no configuration"); assert_eq!(provider.name(), "espeak"); - } - #[test] - fn test_factory_builds_piper_from_model() { - let provider = create_single_tts(&piper_config(None, Some("/m/zh.onnx"), None)) - .expect("piper entry with a model should build"); - assert_eq!(provider.name(), "piper"); + let aliased = create_single_tts(&espeak_config("espeak_ng", Some("cmn+f3"), Some(175))) + .expect("espeak_ng entry should build"); + assert_eq!(aliased.name(), "espeak"); } fn piper_config( @@ -719,16 +645,6 @@ mod tests { assert!(resolved.ends_with("/v/zh.onnx"), "unexpected: {resolved}"); } - #[test] - fn test_openai_engine_from_str_and_display() { - assert_eq!("openai".parse::().ok(), Some(TtsEngine::OpenAi)); - assert_eq!( - "openai_tts".parse::().ok(), - Some(TtsEngine::OpenAi) - ); - assert_eq!(TtsEngine::OpenAi.to_string(), "openai"); - } - fn openai_config(model: Option<&str>, voice: Option<&str>) -> TtsProviderConfig { TtsProviderConfig { name: "openai".to_string(), @@ -773,51 +689,6 @@ mod tests { assert!(provider.is_available()); } - #[test] - fn test_resolve_openai_errors_when_absent() { - let providers: Vec = vec![]; - let err = resolve_tts_provider(&providers, &["openai", "openai_tts"], None, 200, None) - .err() - .expect("expected error with empty config") - .to_string(); - assert!( - err.contains("openai provider not found in config"), - "unexpected error: {err}" - ); - } - - #[test] - fn test_google_still_maps_to_google() { - assert!(matches!( - "google".parse::(), - Ok(TtsEngine::Google) - )); - } - - #[test] - #[cfg(target_os = "macos")] - fn test_create_macos_tts() { - let providers = vec![TtsProviderConfig { - name: "macos".to_string(), - model: None, - voice: Some("Tingting".to_string()), - api_key: None, - rate: Some(200), - volume: Some(80), - path: None, - service_account_key: None, - language_code: None, - speed: None, - stability: None, - style: None, - style_prompt: None, - }]; - - let result = create_tts_from_config(&providers); - assert!(result.is_ok()); - assert_eq!(result.unwrap().name(), "macos"); - } - #[test] #[cfg(target_os = "macos")] fn test_create_tts_fallback() { @@ -899,22 +770,19 @@ mod tests { ); assert!(result.is_ok()); assert_eq!(result.unwrap().name(), "macos"); - } - #[test] - fn test_resolve_tts_provider_errors_when_engine_absent() { - let providers: Vec = vec![]; - let result = resolve_tts_provider(&providers, &["google", "gemini"], None, 200, None); - assert!(result.is_err()); - } + // Other engines resolve from config the same way (aliases are the ones + // the dispatch arms pass) + let espeak = vec![espeak_config("espeak", Some("cmn+f3"), Some(175))]; + let provider = resolve_tts_provider(&espeak, ESPEAK_ALIASES, None, 200, None) + .expect("configured espeak entry should resolve"); + assert_eq!(provider.name(), "espeak"); - #[test] - fn test_resolve_tts_provider_macos_errors_when_absent() { - // config is the single source of truth: an unconfigured engine errors, - // even the credential-free macOS one. - let providers: Vec = vec![]; - let result = resolve_tts_provider(&providers, &["macos", "say"], None, 200, None); - assert!(result.is_err()); + // The CLI `--voice` overlay reaches piper's model knob + let piper = vec![piper_config(None, Some("/m/a.onnx"), None)]; + let provider = resolve_tts_provider(&piper, PIPER_ALIASES, Some("/m/b.onnx"), 200, None) + .expect("configured piper entry should resolve"); + assert_eq!(provider.name(), "piper"); } #[test] diff --git a/src/tts/piper.rs b/src/tts/piper.rs index 44aca90..1222fc3 100644 --- a/src/tts/piper.rs +++ b/src/tts/piper.rs @@ -267,32 +267,19 @@ mod tests { "1.00" ] ); - } - - #[test] - fn test_piper_args_faster_rate_shortens_phonemes() { - assert_scale(&args(400), "0.50"); - } - - #[test] - fn test_piper_args_slower_rate_lengthens_phonemes() { - assert_scale(&args(100), "2.00"); - } - - #[test] - fn test_piper_args_clamps_very_slow_rate() { - assert_scale(&args(50), "2.00"); - } - #[test] - fn test_piper_args_clamps_very_fast_rate() { - assert_scale(&args(1000), "0.50"); - } - - #[test] - fn test_piper_args_zero_rate_does_not_panic() { - // 200/0 is infinity, which must clamp rather than blow up. - assert_scale(&args(0), "2.00"); + // (rate, length-scale): 300 is an in-range point, 400/100 sit on the clamp + // edges, beyond them the scale clamps, and 0 (200/0 = inf) must clamp, not panic + for (rate, scale) in [ + (300, "0.67"), + (400, "0.50"), + (100, "2.00"), + (50, "2.00"), + (1000, "0.50"), + (0, "2.00"), + ] { + assert_scale(&args(rate), scale); + } } // Synthesis tests inject `true`/`false` as the "engine": they exercise the @@ -327,12 +314,7 @@ mod tests { // so Ok(false) proves the guard runs before the spawn. let provider = PiperProvider::new("/m/zh.onnx", 200, 80).with_binary("false"); assert!(!provider.speak("").await.expect("empty text must not error")); - } - - #[tokio::test] - async fn test_speak_ideographic_space_is_a_no_op() { - // U+3000 is whitespace, so a "blank" CJK message must not speak. - let provider = PiperProvider::new("/m/zh.onnx", 200, 80).with_binary("false"); + // U+3000 is whitespace, so a "blank" CJK message must not speak assert!(!provider .speak("\u{3000}") .await @@ -343,7 +325,7 @@ mod tests { fn test_is_available_false_when_model_missing() { // Deterministic whether or not piper itself is installed: a voice model // that isn't there means piper cannot speak. - let provider = PiperProvider::new("/nonexistent/zh.onnx", 200, 100); + let provider = PiperProvider::new("/nonexistent/zh.onnx", 200, 100).with_binary("sh"); assert!(!provider.is_available()); } diff --git a/src/tts/xai.rs b/src/tts/xai.rs index 27b3add..a0f2654 100644 --- a/src/tts/xai.rs +++ b/src/tts/xai.rs @@ -168,19 +168,16 @@ mod tests { assert_eq!(provider.volume, 100); assert!(provider.is_available()); assert!(provider.estimate_cost(1000) > 0.0); - } - #[test] - fn test_custom_voice_and_language() { - let provider = XaiTtsProvider::new( + let custom = XaiTtsProvider::new( "test-api-key".to_string(), "rex".to_string(), Some("zh".to_string()), 75, ); - assert_eq!(provider.voice_id, "rex"); - assert_eq!(provider.language, "zh"); - assert_eq!(provider.volume, 75); + assert_eq!(custom.voice_id, "rex"); + assert_eq!(custom.language, "zh"); + assert_eq!(custom.volume, 75); } #[test] diff --git a/tests/cli.rs b/tests/cli.rs index ff6a041..d0b2177 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -395,30 +395,6 @@ fn test_say_local() { .success(); } -#[test] -fn test_say_volume() { - let env = TestEnv::new(); - env.setup_with_config(&config_without_llm()); - env.mute(); - - env.cmd() - .args(["say", "hello", "--tts", LOCAL_TTS, "--volume", "50"]) - .assert() - .success(); -} - -#[test] -fn test_say_audio_no_config() { - let env = TestEnv::new(); - env.setup_with_config(&config_without_llm()); - - env.cmd() - .args(["say", "hello", "--tts", "audio_file"]) - .assert() - .failure() - .stderr(predicate::str::contains("audio_file provider not found")); -} - #[test] fn test_notification_hook() { let env = TestEnv::new(); @@ -430,6 +406,7 @@ fn test_notification_hook() { .write_stdin(notification_json("Test notification", "permission_prompt")) .assert() .success() + .stdout(predicate::str::contains("Queue lock acquired")) .stdout(predicate::str::contains("Speaking notification")); } @@ -446,20 +423,6 @@ fn test_stop_hook_active() { .stdout(predicate::str::contains("preventing infinite loop")); } -#[test] -fn test_queue_lock_acquired() { - let env = TestEnv::new(); - env.setup_with_config(&config_without_llm()); - env.mute(); - - env.cmd_debug() - .arg("json") - .write_stdin(notification_json("Queue test", "permission_prompt")) - .assert() - .success() - .stdout(predicate::str::contains("Queue lock acquired")); -} - #[test] fn test_queue_disabled() { let env = TestEnv::new(); @@ -476,43 +439,3 @@ fn test_queue_disabled() { .success() .stdout(predicate::str::contains("queue disabled")); } - -#[test] -fn test_queue_concurrent() { - use std::io::Write; - use std::process::Stdio; - - let env = TestEnv::new(); - env.setup_with_config(&config_without_llm()); - env.mute(); - - let bin = assert_cmd::cargo::cargo_bin!("sumvox"); - let spawn = |message: &str| { - let mut child = std::process::Command::new(bin) - .arg("json") - .env("HOME", env.home_path()) - .env_remove("SUMVOX_DISABLE") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("Failed to spawn child"); - child - .stdin - .take() - .unwrap() - .write_all(notification_json(message, "permission_prompt").as_bytes()) - .unwrap(); - child - }; - - let (a, b) = (spawn("Concurrent A"), spawn("Concurrent B")); - for child in [a, b] { - let output = child.wait_with_output().expect("Failed to wait for child"); - assert!( - output.status.success(), - "child failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } -} diff --git a/tests/e2e.rs b/tests/e2e.rs index 8fdf558..7e5a85d 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -150,24 +150,6 @@ stop_tts_provider = "macos" // LLM — sum Command // ============================================================================ -#[test] -#[ignore = "e2e-network"] -fn test_sum_no_speak() { - let env = TestEnv::new(); - env.setup_base_config(); - - env.cmd() - .args([ - "sum", - "The quick brown fox jumps over the lazy dog", - "--no-speak", - ]) - .timeout(std::time::Duration::from_secs(30)) - .assert() - .success() - .stdout(predicate::str::is_empty().not()); -} - #[test] #[ignore = "e2e-network"] fn test_sum_stdin() { @@ -222,25 +204,6 @@ fn test_say_audio_single_file() { .stdout(predicate::str::contains("Playing audio file")); } -#[test] -#[ignore = "e2e-audio"] -fn test_say_audio_directory() { - let env = TestEnv::new(); - - // Create a directory with multiple WAV files - let audio_dir = env.home_path().join("sounds"); - fs::create_dir_all(&audio_dir).unwrap(); - create_minimal_wav(&audio_dir.join("sound1.wav")); - create_minimal_wav(&audio_dir.join("sound2.wav")); - - env.setup_with_config(&config_with_audio_file(audio_dir.to_str().unwrap())); - - env.cmd() - .args(["say", "ignored text", "--tts", "audio_file"]) - .assert() - .success(); -} - // ============================================================================ // LLM + TTS Full Flow // ============================================================================ @@ -263,17 +226,3 @@ fn test_sum_full_flow_local() { .success() .stdout(predicate::str::is_empty().not()); } - -#[test] -#[ignore = "e2e-network"] -fn test_sum_full_flow_google_tts() { - let env = TestEnv::new(); - env.setup_base_config(); - - env.cmd() - .args(["sum", "Hello world", "--tts", "google"]) - .timeout(std::time::Duration::from_secs(30)) - .assert() - .success() - .stdout(predicate::str::is_empty().not()); -} From eebdc0ca05ee50947000c1ccdcbe87af002b31a4 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 11:52:19 +0800 Subject: [PATCH 09/20] build: drop unused mockito Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 172 +---------------------------------------------------- Cargo.toml | 1 - 2 files changed, 2 insertions(+), 171 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d2442d0..4c1d7fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,16 +70,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "assert_cmd" version = "2.2.2" @@ -246,15 +236,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -458,18 +439,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -479,7 +448,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", "rand_core 0.10.1", "wasm-bindgen", ] @@ -554,12 +523,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hyper" version = "1.11.0" @@ -574,7 +537,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -838,15 +800,6 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.33" @@ -891,31 +844,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "mockito" -version = "1.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" -dependencies = [ - "assert-json-diff", - "bytes", - "colored", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "log", - "pin-project-lite", - "rand 0.9.5", - "regex", - "serde_json", - "serde_urlencoded", - "similar", - "tokio", -] - [[package]] name = "nix" version = "0.29.0" @@ -995,29 +923,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "pem" version = "3.0.6" @@ -1168,12 +1073,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1187,20 +1086,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.2" @@ -1222,16 +1111,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -1241,15 +1120,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -1265,15 +1135,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -1446,12 +1307,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "serde" version = "1.0.229" @@ -1563,12 +1418,6 @@ dependencies = [ "libc", ] -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "simple_asn1" version = "0.6.4" @@ -1632,7 +1481,6 @@ dependencies = [ "clap", "dirs 5.0.1", "jsonwebtoken", - "mockito", "nix", "predicates", "rand 0.8.7", @@ -1844,7 +1692,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -2112,15 +1959,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -2432,12 +2270,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index 1815962..2d5d440 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,6 @@ rand = "0.8" jsonwebtoken = "9" [dev-dependencies] -mockito = "1" tempfile = "3" assert_cmd = "2" predicates = "3" From faf134d8e1c74191d75d3d0c7997361432558fc1 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 12:03:42 +0800 Subject: [PATCH 10/20] refactor(pipeline): extract fallback loops for injection The LLM and TTS fallback loops now take already-built providers so tests can inject fakes. Provider creation stays lazy in the callers. The "Trying LLM provider" and "Using TTS provider" info logs no longer include the model and voice, since the loops only see the provider. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/pipeline.rs | 120 ++++++++++++++++++++++++++++-------------------- 1 file changed, 69 insertions(+), 51 deletions(-) diff --git a/src/pipeline.rs b/src/pipeline.rs index d6c224c..5865e7c 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -5,7 +5,7 @@ use std::time::Duration; use crate::config::{effective_disable_thinking, SumvoxConfig, TtsProviderConfig}; use crate::error::Result; -use crate::llm::GenerationRequest; +use crate::llm::{GenerationRequest, LlmProvider}; use crate::notify_log; use crate::provider_factory::ProviderFactory; use crate::tts::{ @@ -150,49 +150,61 @@ pub async fn generate_summary( // Try each provider in config order until one succeeds. // Build a per-provider GenerationRequest so each gets its own effective disable_thinking. - for provider_config in &llm_config.providers { - let disable_thinking = effective_disable_thinking(provider_config, &llm_config.parameters); - + let mut attempts = llm_config.providers.iter().map(|provider_config| { let request = GenerationRequest { system_message: system_message.clone(), prompt: prompt.to_string(), max_tokens: llm_config.parameters.max_tokens, temperature: llm_config.parameters.temperature, - disable_thinking, + disable_thinking: effective_disable_thinking(provider_config, &llm_config.parameters), }; + let provider = ProviderFactory::create_single(provider_config) + .map_err(|e| format!("{}: {}", provider_config.name, e)); + (provider, request) + }); - match ProviderFactory::create_single(provider_config) { - Ok(provider) => { - if !provider.is_available() { - tracing::debug!("Provider {} not available, trying next", provider.name()); - continue; - } + Ok(generate_with_fallback(&mut attempts).await) +} - tracing::info!( - "Trying LLM provider: {} (model: {})", - provider_config.name, - provider_config.model - ); +type LlmAttempt = ( + std::result::Result, String>, + GenerationRequest, +); - match provider.generate(&request).await { - Ok(response) => { - tracing::info!("Provider {} succeeded", provider.name()); - tracing::debug!( - "LLM usage: {} input tokens, {} output tokens", - response.input_tokens, - response.output_tokens - ); +type TtsCandidate = std::result::Result, String>; - return Ok(response.text.trim().to_string()); - } - Err(e) => { - tracing::warn!("Provider {} failed: {}, trying next", provider.name(), e); - continue; - } - } +/// Run each (provider, request) in order until one generates; empty string if none do. +/// An `Err` entry is a provider that could not be created and is skipped. +async fn generate_with_fallback(attempts: &mut dyn Iterator) -> String { + for (provider, request) in attempts { + let provider = match provider { + Ok(p) => p, + Err(e) => { + tracing::debug!("Failed to create provider {}", e); + continue; + } + }; + + if !provider.is_available() { + tracing::debug!("Provider {} not available, trying next", provider.name()); + continue; + } + + tracing::info!("Trying LLM provider: {}", provider.name()); + + match provider.generate(&request).await { + Ok(response) => { + tracing::info!("Provider {} succeeded", provider.name()); + tracing::debug!( + "LLM usage: {} input tokens, {} output tokens", + response.input_tokens, + response.output_tokens + ); + + return response.text.trim().to_string(); } Err(e) => { - tracing::debug!("Failed to create provider {}: {}", provider_config.name, e); + tracing::warn!("Provider {} failed: {}, trying next", provider.name(), e); continue; } } @@ -200,7 +212,7 @@ pub async fn generate_summary( // All providers failed tracing::error!("All LLM providers failed"); - Ok(String::new()) + String::new() } /// Speak text using TTS @@ -345,9 +357,7 @@ async fn speak_with_provider_fallback( text: &str, volume_override: Option, ) -> Result<()> { - let mut last_error = None; - - for provider_config in providers { + let mut candidates = providers.iter().filter_map(|provider_config| { // Skip audio_file providers - they play sound effects, // not speech synthesis, and cannot render arbitrary text. if matches!( @@ -357,7 +367,7 @@ async fn speak_with_provider_fallback( tracing::debug!( "Skipping audio_file provider in fallback chain (not a speech synthesizer)" ); - continue; + return None; } // Apply volume override if provided (hook-level volume takes priority) @@ -366,16 +376,29 @@ async fn speak_with_provider_fallback( config_with_volume.volume = Some(vol); } - // Try to create provider - let provider = match create_single_tts(&config_with_volume) { + Some( + create_single_tts(&config_with_volume) + .map_err(|e| format!("{}: {}", provider_config.name, e)), + ) + }); + + speak_with_fallback(&mut candidates, text).await +} + +/// Speak with the first provider that is available and succeeds. An `Err` entry is a +/// provider that could not be created. Always `Ok`: total failure is only logged. +async fn speak_with_fallback( + providers: &mut dyn Iterator, + text: &str, +) -> Result<()> { + let mut last_error = None; + + for provider in providers { + let provider = match provider { Ok(p) => p, Err(e) => { - tracing::debug!( - "Failed to create TTS provider {}: {}", - provider_config.name, - e - ); - last_error = Some(format!("{}: {}", provider_config.name, e)); + tracing::debug!("Failed to create TTS provider {}", e); + last_error = Some(e); continue; } }; @@ -390,12 +413,7 @@ async fn speak_with_provider_fallback( continue; } - // Log selected provider - tracing::info!( - "Using TTS provider: {} (voice: {})", - provider_config.name, - provider_config.voice.as_deref().unwrap_or("default") - ); + tracing::info!("Using TTS provider: {}", provider.name()); // Estimate and log cost for cloud providers let cost = provider.estimate_cost(text.len()); From d4b4081badb73dd88a404ff6d225717a2ba724ec Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 12:03:45 +0800 Subject: [PATCH 11/20] test(pipeline): cover LLM and TTS fallback switching Co-Authored-By: Claude Opus 5.5 (1M context) --- src/pipeline.rs | 3 + src/pipeline/tests.rs | 235 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 src/pipeline/tests.rs diff --git a/src/pipeline.rs b/src/pipeline.rs index 5865e7c..81c1be6 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -456,3 +456,6 @@ async fn speak_with_fallback( Ok(()) } + +#[cfg(test)] +mod tests; diff --git a/src/pipeline/tests.rs b/src/pipeline/tests.rs new file mode 100644 index 0000000..a0619ae --- /dev/null +++ b/src/pipeline/tests.rs @@ -0,0 +1,235 @@ +use super::*; +use crate::error::{LlmError, LlmResult, VoiceError}; +use crate::llm::GenerationResponse; +use async_trait::async_trait; +use std::sync::{Arc, Mutex}; + +async fn run_llm(attempts: Vec) -> String { + generate_with_fallback(&mut attempts.into_iter()).await +} + +async fn run_tts(providers: Vec, text: &str) -> Result<()> { + speak_with_fallback(&mut providers.into_iter(), text).await +} + +type Calls = Arc>>; + +struct FakeLlm { + name: &'static str, + available: bool, + reply: Option<&'static str>, + calls: Calls, +} + +#[async_trait] +impl LlmProvider for FakeLlm { + fn name(&self) -> &str { + self.name + } + fn is_available(&self) -> bool { + self.available + } + async fn generate(&self, _request: &GenerationRequest) -> LlmResult { + self.calls.lock().unwrap().push(self.name.to_string()); + match self.reply { + Some(text) => Ok(GenerationResponse { + text: text.to_string(), + input_tokens: 0, + output_tokens: 0, + }), + None => Err(LlmError::Request("boom".into())), + } + } +} + +fn llm( + name: &'static str, + available: bool, + reply: Option<&'static str>, + calls: &Calls, +) -> ( + std::result::Result, String>, + GenerationRequest, +) { + let provider: Box = Box::new(FakeLlm { + name, + available, + reply, + calls: calls.clone(), + }); + (Ok(provider), request()) +} + +fn request() -> GenerationRequest { + GenerationRequest { + system_message: None, + prompt: "p".into(), + max_tokens: 10, + temperature: 0.0, + disable_thinking: false, + } +} + +#[tokio::test] +async fn llm_failing_provider_falls_through_to_next() { + let calls = Calls::default(); + let out = run_llm(vec![ + llm("a", true, None, &calls), + llm("b", true, Some(" summary \n"), &calls), + ]) + .await; + assert_eq!(out, "summary"); + assert_eq!(*calls.lock().unwrap(), ["a", "b"]); +} + +#[tokio::test] +async fn llm_unavailable_and_uncreatable_providers_are_skipped() { + let calls = Calls::default(); + let out = run_llm(vec![ + (Err("a: no key".to_string()), request()), + llm("b", false, Some("never"), &calls), + llm("c", true, Some("ok"), &calls), + ]) + .await; + assert_eq!(out, "ok"); + assert_eq!(*calls.lock().unwrap(), ["c"]); +} + +#[tokio::test] +async fn llm_all_failing_returns_empty_string() { + let calls = Calls::default(); + let out = run_llm(vec![ + llm("a", true, None, &calls), + llm("b", false, Some("x"), &calls), + ]) + .await; + assert_eq!(out, ""); + assert_eq!(*calls.lock().unwrap(), ["a"]); +} + +#[tokio::test] +async fn llm_first_success_stops_the_chain() { + let calls = Calls::default(); + run_llm(vec![ + llm("a", true, Some("one"), &calls), + llm("b", true, Some("two"), &calls), + ]) + .await; + assert_eq!(*calls.lock().unwrap(), ["a"]); +} + +struct FakeTts { + name: &'static str, + available: bool, + ok: bool, + tags: bool, + spoken: Calls, +} + +#[async_trait] +impl TtsProvider for FakeTts { + fn name(&self) -> &str { + self.name + } + fn is_available(&self) -> bool { + self.available + } + async fn speak(&self, text: &str) -> Result { + self.spoken + .lock() + .unwrap() + .push(format!("{}:{}", self.name, text)); + if self.ok { + Ok(true) + } else { + Err(VoiceError::Config("tts boom".into())) + } + } + fn estimate_cost(&self, _char_count: usize) -> f64 { + 0.0 + } + fn supports_audio_tags(&self) -> bool { + self.tags + } +} + +fn tts( + name: &'static str, + available: bool, + ok: bool, + tags: bool, + spoken: &Calls, +) -> std::result::Result, String> { + Ok(Box::new(FakeTts { + name, + available, + ok, + tags, + spoken: spoken.clone(), + })) +} + +#[tokio::test] +async fn tts_failing_provider_falls_through_to_next() { + let spoken = Calls::default(); + run_tts( + vec![ + tts("a", true, false, false, &spoken), + tts("b", true, true, false, &spoken), + tts("c", true, true, false, &spoken), + ], + "hi", + ) + .await + .unwrap(); + assert_eq!(*spoken.lock().unwrap(), ["a:hi", "b:hi"]); +} + +#[tokio::test] +async fn tts_unavailable_and_uncreatable_providers_are_skipped() { + let spoken = Calls::default(); + run_tts( + vec![ + Err("a: bad config".to_string()), + tts("b", false, true, false, &spoken), + tts("c", true, true, false, &spoken), + ], + "hi", + ) + .await + .unwrap(); + assert_eq!(*spoken.lock().unwrap(), ["c:hi"]); +} + +#[tokio::test] +async fn tts_total_failure_is_silent_ok() { + let spoken = Calls::default(); + let result = run_tts( + vec![ + tts("a", true, false, false, &spoken), + Err("b: bad".to_string()), + ], + "hi", + ) + .await; + assert!(result.is_ok()); + assert_eq!(*spoken.lock().unwrap(), ["a:hi"]); +} + +#[tokio::test] +async fn tts_audio_tag_stripped_only_for_providers_without_tag_support() { + let spoken = Calls::default(); + run_tts( + vec![ + tts("eleven", true, false, true, &spoken), + tts("plain", true, true, false, &spoken), + ], + "[satisfied] done", + ) + .await + .unwrap(); + assert_eq!( + *spoken.lock().unwrap(), + ["eleven:[satisfied] done", "plain:done"] + ); +} From 50d90d25c65d2e03a6f79374b842e5e7641aa0f7 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 11:58:24 +0800 Subject: [PATCH 12/20] test(queue): cover lock contention timeout and release Co-Authored-By: Claude Opus 5.5 (1M context) --- src/queue.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/queue.rs b/src/queue.rs index 36a247d..0240a74 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -146,6 +146,26 @@ mod tests { assert!(lock2.is_ok()); } + #[tokio::test] + async fn test_contended_lock_times_out_then_succeeds_after_release() { + let temp_dir = tempdir().unwrap(); + let mut queue = NotificationQueue::new(Some(Duration::from_millis(200))).unwrap(); + queue.lock_file_path = temp_dir.path().join("test.lock"); + + let held = QueueLock::acquire(&queue).await.unwrap(); + + let started = Instant::now(); + let err = QueueLock::acquire(&queue) + .await + .err() + .expect("must time out"); + assert!(err.to_string().contains("timeout"), "got: {err}"); + assert!(started.elapsed() >= Duration::from_millis(200)); + + drop(held); + assert!(QueueLock::acquire(&queue).await.is_ok()); + } + #[test] fn test_ensure_lock_dir_creates_directory() { let temp_dir = tempdir().unwrap(); From b1150041df21c3bc5198ef8d3cbb2e295ae066c0 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 11:59:05 +0800 Subject: [PATCH 13/20] test(tts): cover required-field errors for google, cloud_tts, xai and elevenlabs Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tts/mod.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/tts/mod.rs b/src/tts/mod.rs index c717177..b4edd93 100644 --- a/src/tts/mod.rs +++ b/src/tts/mod.rs @@ -681,6 +681,40 @@ mod tests { assert!(err.contains("model is required"), "unexpected error: {err}"); } + #[test] + fn test_required_fields_per_provider() { + let dir = tempfile::tempdir().unwrap(); + let sa_path = dir.path().join("sa.json"); + std::fs::write(&sa_path, "{}").unwrap(); + let sa = sa_path.to_str(); + + // (provider, model, voice, service_account_key, expected error fragment). + // Each case supplies every other required field, so only one is missing. + let cases = [ + ("google", None, Some("Aoede"), None, "model is required"), + ("google", Some("m"), None, None, "voice is required"), + ("cloud_tts", None, None, sa, "voice is required"), + ("cloud_tts", None, Some("v"), None, "service_account_key"), + ("xai", None, None, None, "voice is required"), + ("elevenlabs", Some("m"), None, None, "voice is required"), + ("elevenlabs", None, Some("v"), None, "model is required"), + ]; + for (name, model, voice, service_account_key, expected) in cases { + let config = TtsProviderConfig { + name: name.to_string(), + model: model.map(str::to_string), + voice: voice.map(str::to_string), + service_account_key: service_account_key.map(str::to_string), + ..openai_config(None, None) + }; + let err = create_single_tts(&config) + .err() + .unwrap_or_else(|| panic!("{name}: expected error containing {expected:?}")) + .to_string(); + assert!(err.contains(expected), "{name}: unexpected error: {err}"); + } + } + #[test] fn test_openai_fully_specified_config() { let provider = create_single_tts(&openai_config(Some("gpt-4o-mini-tts"), Some("nova"))) From 818b959f99d658166ca05cd003fb559fca225a3a Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 12:00:15 +0800 Subject: [PATCH 14/20] test(cli): cover stop hook summarization, SUMVOX_DISABLE and generic stdin auto-detect Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/cli.rs | 265 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/tests/cli.rs b/tests/cli.rs index d0b2177..2149320 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -5,7 +5,11 @@ use assert_cmd::cargo::cargo_bin_cmd; use assert_cmd::Command; use predicates::prelude::*; use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::thread; use tempfile::TempDir; /// The local TTS engine for the platform under test (configs below name `macos`). @@ -439,3 +443,264 @@ fn test_queue_disabled() { .success() .stdout(predicate::str::contains("queue disabled")); } + +/// Std-only fake Ollama: answers every POST with `reply` as the generated text and +/// records each request body. Returns (base_url, recorded bodies). +fn fake_ollama(reply: &'static str) -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let bodies = Arc::new(Mutex::new(Vec::new())); + let recorded = bodies.clone(); + thread::spawn(move || { + for mut stream in listener.incoming().flatten() { + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + let body_start = loop { + let n = stream.read(&mut chunk).unwrap_or(0); + if n == 0 { + break None; + } + buf.extend_from_slice(&chunk[..n]); + if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + break Some(pos + 4); + } + }; + let Some(body_start) = body_start else { + continue; + }; + let headers = String::from_utf8_lossy(&buf[..body_start]).to_lowercase(); + let content_length: usize = headers + .lines() + .find_map(|l| l.strip_prefix("content-length:")) + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(0); + while buf.len() < body_start + content_length { + let n = stream.read(&mut chunk).unwrap_or(0); + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + } + recorded + .lock() + .unwrap() + .push(String::from_utf8_lossy(&buf[body_start..]).into_owned()); + + let payload = serde_json::json!({ + "response": reply, + "prompt_eval_count": 1, + "eval_count": 1 + }) + .to_string(); + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + payload.len(), + payload + ); + } + }); + (base_url, bodies) +} + +/// A base_url nothing listens on, so the LLM call fails fast with connection refused. +fn dead_ollama_url() -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + format!("http://{}", listener.local_addr().unwrap()) +} + +fn config_with_ollama(base_url: &str, content_source: &str) -> String { + format!( + r#"[llm] +[[llm.providers]] +name = "ollama" +model = "fake-model" +base_url = "{base_url}" +timeout = 5 + +[llm.parameters] +max_tokens = 100 +temperature = 0.3 + +[tts] +[[tts.providers]] +name = "macos" +rate = 200 + +[summarization] +turns = 1 +content_source = "{content_source}" +system_message = "Test" +prompt_template = "Summarize: {{context}}" +fallback_message = "Test completed" + +[hooks.claude_code] +notification_filter = ["*"] +queue_timeout = 0 +"# + ) +} + +fn stop_json(transcript_path: &Path, last_assistant_message: Option<&str>) -> String { + let mut json = serde_json::json!({ + "session_id": "e2e-test", + "transcript_path": transcript_path, + "hook_event_name": "Stop", + }); + if let Some(msg) = last_assistant_message { + json["last_assistant_message"] = msg.into(); + } + json.to_string() +} + +fn write_transcript(dir: &Path, assistant_text: &str) -> std::path::PathBuf { + let path = dir.join("transcript.jsonl"); + let lines = [ + serde_json::json!({"type":"user","message":{"role":"user","content":"please do it"}}), + serde_json::json!({"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":assistant_text}]}}), + ]; + let body: Vec = lines.iter().map(|l| l.to_string()).collect(); + fs::write(&path, body.join("\n")).unwrap(); + path +} + +fn history(env: &TestEnv) -> String { + fs::read_to_string(env.home_path().join(".config/sumvox/history.log")).unwrap_or_default() +} + +#[test] +fn test_stop_hook_summarizes_transcript_and_speaks_summary() { + let env = TestEnv::new(); + let (url, bodies) = fake_ollama("CANNED SUMMARY"); + env.setup_with_config(&config_with_ollama(&url, "transcript")); + env.mute(); + let transcript = write_transcript(env.home_path(), "I refactored the parser"); + + env.cmd() + .arg("json") + .write_stdin(stop_json(&transcript, None)) + .assert() + .success(); + + let bodies = bodies.lock().unwrap(); + assert_eq!(bodies.len(), 1, "LLM should be called once"); + assert!( + bodies[0].contains("I refactored the parser"), + "transcript text must reach the LLM prompt: {}", + bodies[0] + ); + assert!( + history(&env).contains("CANNED SUMMARY"), + "summary must reach the TTS step" + ); +} + +#[test] +fn test_stop_hook_last_message_source_skips_transcript() { + let env = TestEnv::new(); + let (url, bodies) = fake_ollama("CANNED SUMMARY"); + env.setup_with_config(&config_with_ollama(&url, "last_message")); + env.mute(); + let missing = env.home_path().join("no-such-transcript.jsonl"); + + env.cmd() + .arg("json") + .write_stdin(stop_json(&missing, Some("message from hook input"))) + .assert() + .success(); + + let bodies = bodies.lock().unwrap(); + assert_eq!(bodies.len(), 1, "LLM should be called once"); + assert!(bodies[0].contains("message from hook input")); + assert!(history(&env).contains("CANNED SUMMARY")); +} + +#[test] +fn test_stop_hook_llm_failure_speaks_fallback_message() { + let env = TestEnv::new(); + env.setup_with_config(&config_with_ollama(&dead_ollama_url(), "last_message")); + env.mute(); + let missing = env.home_path().join("no-such-transcript.jsonl"); + + env.cmd() + .arg("json") + .write_stdin(stop_json(&missing, Some("anything"))) + .assert() + .success(); + + assert!(history(&env).contains("Test completed")); +} + +#[test] +fn test_stop_hook_unreadable_transcript_fails() { + let env = TestEnv::new(); + env.setup_with_config(&config_with_ollama(&dead_ollama_url(), "transcript")); + env.mute(); + let missing = env.home_path().join("no-such-transcript.jsonl"); + + env.cmd() + .arg("json") + .write_stdin(stop_json(&missing, None)) + .assert() + .failure() + .stderr(predicate::str::contains("Failed to open transcript")); + assert_eq!(history(&env), "", "nothing should be spoken"); +} + +#[test] +fn test_sumvox_disable_short_circuits() { + let env = TestEnv::new(); + env.setup_with_config(&config_without_llm()); + env.mute(); + + env.cmd() + .env("SUMVOX_DISABLE", "1") + .args(["say", "should not be recorded"]) + .assert() + .success(); + + assert_eq!( + history(&env), + "", + "disabled run must not reach the pipeline" + ); +} + +#[test] +fn test_muted_say_records_history_without_playing() { + let env = TestEnv::new(); + let sound = env.home_path().join("sound.wav"); + fs::write(&sound, b"not real audio").unwrap(); + env.setup_with_config(&config_with_audio_file(sound.to_str().unwrap())); + env.mute(); + + env.cmd() + .args(["say", "quiet please", "--tts", "audio_file"]) + .assert() + .success(); + + assert!(history(&env).contains("quiet please")); + assert!( + !env.home_path().join(".config/sumvox/now_playing").exists(), + "muted run must not start playback" + ); +} + +#[test] +fn test_bare_invocation_autodetects_generic_json_on_stdin() { + let env = TestEnv::new(); + let (url, bodies) = fake_ollama("GENERIC SUMMARY"); + env.setup_with_config(&config_with_ollama(&url, "transcript")); + env.mute(); + + env.cmd() + .write_stdin(r#"{"text":"some tool output"}"#) + .assert() + .success() + .stdout(predicate::str::contains("GENERIC SUMMARY")); + + let bodies = bodies.lock().unwrap(); + assert_eq!(bodies.len(), 1, "LLM should be called once"); + assert!(bodies[0].contains("some tool output")); + assert!(history(&env).contains("GENERIC SUMMARY")); +} From 46f682b1bf43f054e02e46a1a30f8d49dab0d53d Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 12:05:48 +0800 Subject: [PATCH 15/20] refactor(llm): extract OpenAI request building into build_request Co-Authored-By: Claude Opus 5.5 (1M context) --- src/llm/openai.rs | 81 ++++++++++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 39 deletions(-) diff --git a/src/llm/openai.rs b/src/llm/openai.rs index 0e1b7e2..d0a978d 100644 --- a/src/llm/openai.rs +++ b/src/llm/openai.rs @@ -116,6 +116,47 @@ fn is_reasoning_model(model_name: &str) -> bool { || model_name.starts_with("gpt-5") } +/// Builds the wire request. `reasoning_effort` follows `disable_thinking` alone +/// (no model-name heuristic): true sends "low", false omits the field. Reasoning +/// models take `max_completion_tokens` and no temperature; standard models take +/// `max_tokens` and temperature. +fn build_request(model_name: &str, request: &GenerationRequest) -> OpenAIRequest { + let mut messages = Vec::new(); + + if let Some(ref system_msg) = request.system_message { + messages.push(Message { + role: "system".to_string(), + content: system_msg.clone(), + }); + } + + messages.push(Message { + role: "user".to_string(), + content: request.prompt.clone(), + }); + + let reasoning_effort = if request.disable_thinking { + Some("low".to_string()) + } else { + None + }; + + let (max_completion_tokens, max_tokens, temperature) = if is_reasoning_model(model_name) { + (Some(request.max_tokens), None, None) + } else { + (None, Some(request.max_tokens), Some(request.temperature)) + }; + + OpenAIRequest { + model: model_name.to_string(), + messages, + max_completion_tokens, + max_tokens, + temperature, + reasoning_effort, + } +} + #[async_trait] impl LlmProvider for OpenAIProvider { fn name(&self) -> &str { @@ -136,45 +177,7 @@ impl LlmProvider for OpenAIProvider { let model_name = self.extract_model_name(); let url = format!("{}/chat/completions", self.base_url); - let mut messages = Vec::new(); - - if let Some(ref system_msg) = request.system_message { - messages.push(Message { - role: "system".to_string(), - content: system_msg.clone(), - }); - } - - messages.push(Message { - role: "user".to_string(), - content: request.prompt.clone(), - }); - - // Set reasoning_effort based solely on disable_thinking flag (no model-name heuristic). - // disable_thinking=true → "low" (minimize reasoning effort) - // disable_thinking=false → omit the field entirely - let reasoning_effort = if request.disable_thinking { - Some("low".to_string()) - } else { - None - }; - - // Reasoning models (o1, o3, o4, gpt-5) use max_completion_tokens and no temperature. - // Standard models use max_tokens and temperature. - let (max_completion_tokens, max_tokens, temperature) = if is_reasoning_model(model_name) { - (Some(request.max_tokens), None, None) - } else { - (None, Some(request.max_tokens), Some(request.temperature)) - }; - - let openai_request = OpenAIRequest { - model: model_name.to_string(), - messages, - max_completion_tokens, - max_tokens, - temperature, - reasoning_effort, - }; + let openai_request = build_request(model_name, request); tracing::debug!("Sending request to OpenAI API: {}", model_name); From 85a391b182ef9fc9b452c966db70eea7127706b4 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 12:06:07 +0800 Subject: [PATCH 16/20] test(llm): drive OpenAI wire-format test through build_request Co-Authored-By: Claude Opus 5.5 (1M context) --- src/llm/openai.rs | 62 +++++++++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/src/llm/openai.rs b/src/llm/openai.rs index d0a978d..e412752 100644 --- a/src/llm/openai.rs +++ b/src/llm/openai.rs @@ -285,33 +285,43 @@ mod tests { assert!(matches!(result.unwrap_err(), LlmError::Unavailable(_))); } - // ── C5: OpenAIRequestSerialization ─────────────────────────────────── - #[test] - fn test_c5_request_wire_format() { - let request = |reasoning: bool| OpenAIRequest { - model: "m".to_string(), - messages: vec![Message { - role: "user".to_string(), - content: "Test".to_string(), - }], - max_completion_tokens: reasoning.then_some(100u32), - max_tokens: (!reasoning).then_some(100u32), - temperature: (!reasoning).then_some(0.3f32), - reasoning_effort: reasoning.then(|| "low".to_string()), - }; - - let reasoning = serde_json::to_value(request(true)).unwrap(); - assert_eq!(reasoning["reasoning_effort"], "low"); - assert!(reasoning.get("max_completion_tokens").is_some()); - assert!(reasoning.get("max_tokens").is_none()); - assert!(reasoning.get("temperature").is_none()); - - let standard = serde_json::to_value(request(false)).unwrap(); - assert!(standard.get("reasoning_effort").is_none()); - assert!(standard.get("max_tokens").is_some()); - assert!(standard.get("temperature").is_some()); - assert!(standard.get("max_completion_tokens").is_none()); + fn test_request_wire_format() { + // (model, disable_thinking, reasoning_model): reasoning_effort follows + // the flag, token fields follow the model family. + for (model, disable_thinking, reasoning_model) in [ + ("o3-mini", true, true), + ("o3-mini", false, true), + ("gpt-4o-mini", true, false), + ("gpt-4o-mini", false, false), + ] { + let request = GenerationRequest { + system_message: None, + prompt: "Test".to_string(), + max_tokens: 100, + temperature: 0.3, + disable_thinking, + }; + let json = serde_json::to_value(build_request(model, &request)).unwrap(); + let ctx = format!("{model} disable_thinking={disable_thinking}"); + + assert_eq!(json["model"], model, "{ctx}"); + assert_eq!( + json.get("reasoning_effort").is_some(), + disable_thinking, + "{ctx}" + ); + if disable_thinking { + assert_eq!(json["reasoning_effort"], "low", "{ctx}"); + } + assert_eq!( + json.get("max_completion_tokens").is_some(), + reasoning_model, + "{ctx}" + ); + assert_eq!(json.get("max_tokens").is_some(), !reasoning_model, "{ctx}"); + assert_eq!(json.get("temperature").is_some(), !reasoning_model, "{ctx}"); + } } #[test] From 5d83553afa2362a6e837bd379d8ab0a4a7032333 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 12:06:34 +0800 Subject: [PATCH 17/20] test(tts): cover espeak and piper estimate_cost Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tts/espeak.rs | 1 + src/tts/piper.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/tts/espeak.rs b/src/tts/espeak.rs index ddc36cb..fd9b407 100644 --- a/src/tts/espeak.rs +++ b/src/tts/espeak.rs @@ -311,5 +311,6 @@ mod tests { !provider.is_available(), "a missing engine must be skipped by the chain, not error" ); + assert_eq!(provider.estimate_cost(1000), 0.0); } } diff --git a/src/tts/piper.rs b/src/tts/piper.rs index 1222fc3..da858c0 100644 --- a/src/tts/piper.rs +++ b/src/tts/piper.rs @@ -338,5 +338,6 @@ mod tests { !provider.is_available(), "a missing engine must be skipped by the chain, not error" ); + assert_eq!(provider.estimate_cost(1000), 0.0); } } From 506ea95ceed0e4b7b9f975f5ecb8b9a30bcfd273 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 12:06:34 +0800 Subject: [PATCH 18/20] test(tts): assert piper factory builds the provider with the resolved model Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tts/mod.rs | 71 ++++++++++++++++++++---------------------------- src/tts/piper.rs | 5 ++++ 2 files changed, 35 insertions(+), 41 deletions(-) diff --git a/src/tts/mod.rs b/src/tts/mod.rs index b4edd93..2b32303 100644 --- a/src/tts/mod.rs +++ b/src/tts/mod.rs @@ -154,6 +154,25 @@ pub fn create_tts_from_config(providers: &[TtsProviderConfig]) -> Result Result { + // For piper, the voice IS a downloaded .onnx model file. `voice` + // takes precedence because it is the field the CLI overlays, so + // `--voice ~/voices/zh.onnx` can select one. + let model_path = piper::resolve_model_path( + config.voice.as_deref(), + config.model.as_deref(), + config.path.as_deref(), + ) + .ok_or_else(|| { + VoiceError::Config( + "piper model is required. Set `model` (or `voice`) to a downloaded .onnx voice path." + .into(), + ) + })?; + let rate = config.rate.unwrap_or(piper::DEFAULT_RATE); + Ok(PiperProvider::new(model_path, rate, volume)) +} + /// Create a single TTS provider from config pub fn create_single_tts(config: &TtsProviderConfig) -> Result> { let volume = config.volume.unwrap_or(100); @@ -294,24 +313,7 @@ pub fn create_single_tts(config: &TtsProviderConfig) -> Result { - // For piper, the voice IS a downloaded .onnx model file. `voice` - // takes precedence because it is the field the CLI overlays, so - // `--voice ~/voices/zh.onnx` can select one. - let model_path = piper::resolve_model_path( - config.voice.as_deref(), - config.model.as_deref(), - config.path.as_deref(), - ) - .ok_or_else(|| { - VoiceError::Config( - "piper model is required. Set `model` (or `voice`) to a downloaded .onnx voice path." - .into(), - ) - })?; - let rate = config.rate.unwrap_or(piper::DEFAULT_RATE); - Ok(Box::new(PiperProvider::new(model_path, rate, volume))) - } + "piper" | "piper_tts" => Ok(Box::new(build_piper(config, volume)?)), "audio_file" | "audio" | "file" => { let path_str = config.path.as_ref().ok_or_else(|| { VoiceError::Config( @@ -513,16 +515,10 @@ mod tests { ); } - // The alias arrays below are exactly the ones the dispatch arms in - // main.rs and hooks/claude_code.rs pass; the arms' existence is enforced by - // the compiler (both `match tts_engine` blocks are exhaustive). - const ESPEAK_ALIASES: &[&str] = &["espeak", "espeak_ng", "espeak-ng"]; - const PIPER_ALIASES: &[&str] = &["piper", "piper_tts"]; - #[test] fn test_dispatch_espeak_errors_when_absent_from_config() { let providers: Vec = vec![]; - let err = resolve_tts_provider(&providers, ESPEAK_ALIASES, None, 200, None) + let err = resolve_tts_provider(&providers, &["espeak"], None, 200, None) .err() .expect("an unconfigured engine must error") .to_string(); @@ -583,19 +579,13 @@ mod tests { } } - /// The `.onnx` path a built piper provider resolved to. + /// The `.onnx` path the factory's piper provider was actually built with. fn built_model_path(config: &TtsProviderConfig) -> String { - let provider = create_single_tts(config).expect("piper entry should build"); - assert_eq!(provider.name(), "piper"); - // Rebuild concretely to inspect the resolved model path. - piper::resolve_model_path( - config.voice.as_deref(), - config.model.as_deref(), - config.path.as_deref(), - ) - .expect("a model path must have resolved") - .to_string_lossy() - .to_string() + build_piper(config, 100) + .expect("piper entry should build") + .model_path() + .to_string_lossy() + .to_string() } #[test] @@ -805,16 +795,15 @@ mod tests { assert!(result.is_ok()); assert_eq!(result.unwrap().name(), "macos"); - // Other engines resolve from config the same way (aliases are the ones - // the dispatch arms pass) + // Other engines resolve from config the same way let espeak = vec![espeak_config("espeak", Some("cmn+f3"), Some(175))]; - let provider = resolve_tts_provider(&espeak, ESPEAK_ALIASES, None, 200, None) + let provider = resolve_tts_provider(&espeak, &["espeak"], None, 200, None) .expect("configured espeak entry should resolve"); assert_eq!(provider.name(), "espeak"); // The CLI `--voice` overlay reaches piper's model knob let piper = vec![piper_config(None, Some("/m/a.onnx"), None)]; - let provider = resolve_tts_provider(&piper, PIPER_ALIASES, Some("/m/b.onnx"), 200, None) + let provider = resolve_tts_provider(&piper, &["piper"], Some("/m/b.onnx"), 200, None) .expect("configured piper entry should resolve"); assert_eq!(provider.name(), "piper"); } diff --git a/src/tts/piper.rs b/src/tts/piper.rs index da858c0..4c4272e 100644 --- a/src/tts/piper.rs +++ b/src/tts/piper.rs @@ -113,6 +113,11 @@ impl PiperProvider { } } + #[cfg(test)] + pub(crate) fn model_path(&self) -> &Path { + &self.model_path + } + /// Swap the spawned program, so tests can inject `true`/`false` stand-ins. #[cfg(test)] fn with_binary(mut self, binary: &str) -> Self { From 96ca60d841b2f4c46d9bfbc390050d0af39c9a0d Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 12:06:34 +0800 Subject: [PATCH 19/20] chore(config): drop orphan test section header Co-Authored-By: Claude Opus 5.5 (1M context) --- src/config.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index b67fa66..cdb69f1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1147,6 +1147,4 @@ turns = 1 ); } } - - // ── C6: per-provider disable_thinking TOML deserialization ────────── } From a036ed69ab43eb49a11071fdc7062257c023c078 Mon Sep 17 00:00:00 2001 From: musingfox Date: Thu, 1 Oct 2026 12:27:35 +0800 Subject: [PATCH 20/20] ci: run ignored audio playback tests in the e2e job These tests left the default tier when they were gated behind ignore; without this step no CI job executed them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0afac8..f034e04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,12 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable + - name: Run audio playback tests + env: + SDL_AUDIODRIVER: dummy + run: "cargo test --bins --verbose -- --ignored audio:: tts::" + timeout-minutes: 10 + - name: Write E2E config env: E2E_CONFIG: ${{ secrets.E2E_CONFIG }}