diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc24d44..f034e04 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 @@ -103,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 }} @@ -118,7 +121,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/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.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 020bf25..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" @@ -44,10 +43,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/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 diff --git a/src/audio/file.rs b/src/audio/file.rs index 681ecab..ee035db 100644 --- a/src/audio/file.rs +++ b/src/audio/file.rs @@ -156,6 +156,9 @@ 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); + assert!(provider.is_available()); + assert_eq!(provider.get_playback_file().unwrap(), temp_file.path()); } #[test] @@ -166,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] @@ -176,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(); @@ -224,48 +217,4 @@ mod tests { .to_string() .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(); - 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()); - } - - #[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 1f15b1b..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!( @@ -522,6 +493,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 +508,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] @@ -567,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); @@ -620,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) @@ -665,28 +626,13 @@ 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"); assert!(result.is_ok()); } - #[test] - #[cfg(target_os = "macos")] - 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")] - 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 @@ -709,25 +655,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/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/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..cdb69f1 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#"{ @@ -857,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] @@ -905,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] @@ -978,61 +953,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#" @@ -1106,40 +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_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; @@ -1169,48 +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); - } - - #[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())); + 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#" @@ -1254,14 +1108,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 { @@ -1288,69 +1134,17 @@ 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)); - } - - // ── 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)); + // (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 + ); + } } } 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 9ef7b44..9bb1af8 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() { @@ -339,284 +338,29 @@ 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 ────────────────────────────────────────── - - #[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); - } + fn test_select_source() { + use crate::config::ContentSource::{LastMessage, Transcript}; - // 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] - 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] - fn test_select_source_transcript_none() { - use crate::config::ContentSource; - let source = select_stop_context_source(ContentSource::Transcript, None); - assert!(matches!(source, StopContextSource::ReadTranscript)); - } - - #[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/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; diff --git a/src/llm/anthropic.rs b/src/llm/anthropic.rs index 11f5e07..888c27d 100644 --- a/src/llm/anthropic.rs +++ b/src/llm/anthropic.rs @@ -190,53 +190,23 @@ 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( - "sk-ant-test-key".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_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] @@ -261,69 +231,9 @@ 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] + #[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..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] @@ -325,52 +296,25 @@ 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()); - } + let disabled = + serde_json::to_value(config(Some(ThinkingConfig { thinking_budget: 0 }))).unwrap(); + assert_eq!(disabled["thinkingConfig"]["thinkingBudget"], 0); - /// 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); - } - - #[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 #[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/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 94cde64..6e9b4b6 100644 --- a/src/llm/ollama.rs +++ b/src/llm/ollama.rs @@ -142,129 +142,46 @@ 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( - "http://localhost:11434".to_string(), - "ollama/llama3.1".to_string(), - Duration::from_secs(30), - ); - - assert_eq!(provider.extract_model_name(), "llama3.1"); - } - - #[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"); - } - - // ── 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, + 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()); } } #[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 #[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..e412752 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); @@ -225,77 +228,39 @@ 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( - "sk-test-key".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_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] @@ -320,95 +285,45 @@ mod tests { assert!(matches!(result.unwrap_err(), LlmError::Unavailable(_))); } - // ── 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(), - 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(); + 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!( - val["reasoning_effort"], - serde_json::Value::String("low".to_string()), - "model={model} should have reasoning_effort=low" + json.get("reasoning_effort").is_some(), + disable_thinking, + "{ctx}" ); - } - } - - #[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" + 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}"); } } - // ── 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" - ); - } - - #[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" - ); - } - #[test] fn test_a2_is_reasoning_model_detection() { assert!(is_reasoning_model("o1-mini")); @@ -425,7 +340,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/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/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/pipeline.rs b/src/pipeline.rs index d6c224c..81c1be6 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()); @@ -438,3 +456,6 @@ async fn speak_with_provider_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"] + ); +} diff --git a/src/queue.rs b/src/queue.rs index 276c19d..0240a74 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -127,33 +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(); - 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(); @@ -173,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(); 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 7481fc3..9bd423e 100644 --- a/src/tts/cloud_tts.rs +++ b/src/tts/cloud_tts.rs @@ -293,21 +293,13 @@ 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()); - } - #[test] - fn test_is_available_empty() { - let p = CloudTtsProvider::new( + let empty = CloudTtsProvider::new( String::new(), "en-US-Standard-A".to_string(), None, @@ -315,7 +307,7 @@ mod tests { None, 100, ); - assert!(!p.is_available()); + assert!(!empty.is_available()); } #[tokio::test] @@ -324,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 @@ -347,20 +332,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."; @@ -426,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] @@ -443,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/cloud_tts_auth.rs b/src/tts/cloud_tts_auth.rs index c7c519f..288245e 100644 --- a/src/tts/cloud_tts_auth.rs +++ b/src/tts/cloud_tts_auth.rs @@ -162,33 +162,16 @@ 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"); - } - - #[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")); - #[test] - fn test_missing_fields() { - let json = r#"{"type":"service_account"}"#; - let result = serde_json::from_str::(json); - assert!(result.is_err()); + // 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()); } } diff --git a/src/tts/elevenlabs.rs b/src/tts/elevenlabs.rs index ac5d8d7..d31aed0 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( @@ -313,34 +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, - ); - // 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 5d40636..fd9b407 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,28 +298,12 @@ 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 .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"); @@ -344,24 +311,6 @@ mod tests { !provider.is_available(), "a missing engine must be skipped by the chain, not error" ); - } - - #[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, - // stronger than asserting the absence of a string. - let argv = args(Some("cmn"), 175); - assert!(argv.contains(&"--stdin".to_string()), "{argv:?}"); + assert_eq!(provider.estimate_cost(1000), 0.0); } } 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 3d15b36..453cdec 100644 --- a/src/tts/macos.rs +++ b/src/tts/macos.rs @@ -114,47 +114,20 @@ 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); - } - - #[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); + assert!(!provider.speak(" ").await.unwrap()); + assert_eq!(provider.estimate_cost(100), 0.0); } // 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")] #[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/src/tts/mod.rs b/src/tts/mod.rs index aea002f..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( @@ -392,36 +394,49 @@ 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()); - #[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) - ); + // 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] @@ -500,24 +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_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![]; - 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(); @@ -527,65 +528,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_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. - assert!("espeakng".parse::().is_err()); - } - fn espeak_config(name: &str, voice: Option<&str>, rate: Option) -> TtsProviderConfig { TtsProviderConfig { name: name.to_string(), @@ -604,25 +546,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( @@ -647,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] @@ -709,16 +635,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(), @@ -755,6 +671,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"))) @@ -763,64 +713,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_cloud_tts_display() { - assert_eq!(TtsEngine::CloudTts.to_string(), "cloud_tts"); - } - - #[test] - fn test_google_still_maps_to_google() { - assert!(matches!( - "google".parse::(), - Ok(TtsEngine::Google) - )); - } - - #[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() { - 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() { @@ -858,9 +750,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()); @@ -895,22 +794,18 @@ 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 + let espeak = vec![espeak_config("espeak", Some("cmn+f3"), Some(175))]; + let provider = resolve_tts_provider(&espeak, &["espeak"], 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"], Some("/m/b.onnx"), 200, None) + .expect("configured piper entry should resolve"); + assert_eq!(provider.name(), "piper"); } #[test] diff --git a/src/tts/openai.rs b/src/tts/openai.rs index 44fb8b3..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()); } @@ -248,20 +221,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()); - } } diff --git a/src/tts/piper.rs b/src/tts/piper.rs index 0030b81..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 { @@ -267,32 +272,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 +319,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,21 +330,10 @@ 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()); } - #[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"); @@ -367,16 +343,6 @@ mod tests { !provider.is_available(), "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"); + assert_eq!(provider.estimate_cost(1000), 0.0); } } diff --git a/src/tts/xai.rs b/src/tts/xai.rs index e40a976..a0f2654 100644 --- a/src/tts/xai.rs +++ b/src/tts/xai.rs @@ -167,19 +167,17 @@ mod tests { assert_eq!(provider.language, "auto"); 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] @@ -188,20 +186,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 new file mode 100644 index 0000000..2149320 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,706 @@ +// 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::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`). +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() + } + + /// 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()); + 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_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 = [] +[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_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")); +} + +#[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_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("Queue lock acquired")) + .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_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")); +} + +/// 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")); +} diff --git a/tests/e2e.rs b/tests/e2e.rs index 5739e81..7e5a85d 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; @@ -114,77 +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_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] @@ -216,211 +146,12 @@ 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" -"# - ) -} - -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) +// LLM — sum Command // ============================================================================ #[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) -// ============================================================================ - -#[test] -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] -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() { let env = TestEnv::new(); env.setup_base_config(); @@ -434,60 +165,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] -fn test_say_local() { - let env = TestEnv::new(); - env.setup_base_config(); - - env.cmd() - .args(["say", "hello", "--tts", LOCAL_TTS]) - .assert() - .success(); -} - -#[test] -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] -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 +183,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(); @@ -520,53 +204,12 @@ fn test_say_audio_single_file() { .stdout(predicate::str::contains("Playing audio file")); } -#[test] -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(); -} - -#[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_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 (2) +// LLM + TTS Full Flow // ============================================================================ #[test] +#[ignore = "e2e-audio"] fn test_sum_full_flow_local() { let env = TestEnv::new(); env.setup_base_config(); @@ -583,171 +226,3 @@ fn test_sum_full_flow_local() { .success() .stdout(predicate::str::is_empty().not()); } - -#[test] -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()); -} - -// ============================================================================ -// Hook Dispatch (3) -// ============================================================================ - -#[test] -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] -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] -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 (3) -// ============================================================================ - -#[test] -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] -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] -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) - ); -} 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" - ); -}