//! Include-based coverage for client audio output sink selection helpers. //! //! Scope: include `client/src/output/audio.rs` and exercise sink discovery //! branches with controlled `pactl` fixtures. //! Targets: `client/src/output/audio.rs`. //! Why: keep sink-resolution behavior deterministic without requiring live //! desktop audio devices in CI. #[allow(warnings)] mod audio_include_contract { include!(env!("LESAVKA_CLIENT_OUTPUT_AUDIO_SRC")); use serial_test::serial; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; use temp_env::with_var; use tempfile::tempdir; fn write_executable(dir: &Path, name: &str, body: &str) { let path = dir.join(name); fs::write(&path, body).expect("write script"); let mut perms = fs::metadata(&path).expect("metadata").permissions(); perms.set_mode(0o755); fs::set_permissions(path, perms).expect("chmod"); } fn with_fake_pactl(script_body: &str, f: impl FnOnce()) { let dir = tempdir().expect("tempdir"); write_executable(dir.path(), "pactl", script_body); let prior = std::env::var("PATH").unwrap_or_default(); let merged = if prior.is_empty() { dir.path().display().to_string() } else { format!("{}:{prior}", dir.path().display()) }; with_var("PATH", Some(merged), f); } #[test] #[serial] fn pick_sink_element_prefers_operator_override() { with_var("LESAVKA_AUDIO_SINK", Some("fakesink sync=false"), || { let sink = pick_sink_element().expect("override sink"); assert_eq!(sink, "fakesink sync=false"); }); } #[test] #[serial] fn pick_sink_element_wraps_bare_device_override_for_pulsesink() { with_var( "LESAVKA_AUDIO_SINK", Some("alsa_output.pci-0000_00_1f.3.analog-stereo"), || { let sink = pick_sink_element().expect("device sink"); assert_eq!( sink, "pulsesink device=\"alsa_output.pci-0000_00_1f.3.analog-stereo\" buffer-time=350000 latency-time=100000 sync=true" ); }, ); } #[test] #[serial] fn pick_sink_element_uses_default_sink_from_pactl_info() { let script = r#"#!/usr/bin/env sh if [ "$1" = "info" ]; then echo "Server String: /run/user/1000/pulse/native" echo "Default Sink: alsa_output.usb-DAC_1234-00.analog-stereo" exit 0 fi exit 0 "#; with_fake_pactl(script, || { with_var("LESAVKA_AUDIO_SINK", None::<&str>, || { let sinks = list_pw_sinks(); assert_eq!( sinks, vec![( "alsa_output.usb-DAC_1234-00.analog-stereo".to_string(), "DEFAULT".to_string() )] ); let sink = pick_sink_element().expect("pick sink"); assert_eq!( sink, "pulsesink device=\"alsa_output.usb-DAC_1234-00.analog-stereo\" buffer-time=350000 latency-time=100000 sync=true" ); }); }); } #[test] #[serial] fn bluetooth_sink_override_uses_more_headroom() { with_var( "LESAVKA_AUDIO_SINK", Some("bluez_output.80_C3_BA_76_26_AB.1"), || { let sink = pick_sink_element().expect("bluetooth sink"); assert_eq!( sink, "pulsesink device=\"bluez_output.80_C3_BA_76_26_AB.1\" buffer-time=750000 latency-time=250000 sync=true" ); }, ); } #[test] #[serial] fn pick_sink_element_falls_back_to_autoaudiosink_without_pactl_default() { let script = r#"#!/usr/bin/env sh if [ "$1" = "info" ]; then echo "Server String: /run/user/1000/pulse/native" echo "Default Source: alsa_input.usb-Mic_1234-00.analog-stereo" exit 0 fi exit 0 "#; with_fake_pactl(script, || { with_var("LESAVKA_AUDIO_SINK", None::<&str>, || { assert!( list_pw_sinks().is_empty(), "no default sink should be parsed" ); let sink = pick_sink_element().expect("fallback sink"); assert_eq!(sink, "autoaudiosink"); }); }); } #[test] #[serial] fn audio_out_new_and_push_are_stable_with_sink_override() { with_var("LESAVKA_AUDIO_SINK", Some("fakesink sync=false"), || { with_var("LESAVKA_AUDIO_GAIN", Some("2.5"), || { with_var("LESAVKA_TAP_AUDIO", Some("1"), || match AudioOut::new() { Ok(out) => { out.push(AudioPacket { id: 0, pts: 1_234, data: vec![0xFF, 0xF1, 0x50, 0x80, 0x00, 0x1F, 0xFC], }); drop(out); } Err(err) => { assert!(!err.to_string().trim().is_empty()); } }); }); }); } #[test] #[serial] fn audio_out_new_returns_error_for_invalid_sink_override() { with_var( "LESAVKA_AUDIO_SINK", Some("definitely-not-a-real-gst-sink"), || { with_var("LESAVKA_TAP_AUDIO", None::<&str>, || { let result = AudioOut::new(); assert!( result.is_err(), "invalid sink override must fail pipeline parsing" ); }); }, ); } #[test] fn live_audio_buffer_leaves_pts_for_appsrc_timestamping() { let _ = gst::init(); let timeline = std::sync::Mutex::new(AudioTimeline::default()); let buffer = live_audio_buffer( AudioPacket { id: 0, pts: 42_666, data: vec![0xFF, 0xF1, 0x50, 0x80, 0x00, 0x1F, 0xFC], }, &timeline, ); assert_eq!(buffer.pts(), None); assert_eq!(timeline.lock().expect("timeline").packets, 1); } #[test] #[serial] fn downstream_audio_pipeline_keeps_working_aac_transport_with_gain_stage() { with_var("LESAVKA_AUDIO_GAIN", Some("3.25"), || { assert_eq!(audio_gain_from_env(), 3.25); }); let desc = audio_output_pipeline_desc("fakesink sync=false", 3.25, false); assert!(desc.contains("aacparse ! avdec_aac")); assert!(desc.contains("volume name=remote_audio_gain volume=3.250")); assert!(desc.contains("level name=remote_audio_level")); assert!(desc.contains("fakesink sync=false")); } #[test] #[serial] fn audio_gain_env_defaults_and_clamps_for_soft_remote_audio() { with_var("LESAVKA_AUDIO_GAIN", None::<&str>, || { assert_eq!(audio_gain_from_env(), 2.0); }); with_var("LESAVKA_AUDIO_GAIN", Some("99"), || { assert_eq!(audio_gain_from_env(), 8.0); }); with_var("LESAVKA_AUDIO_GAIN", Some("-1"), || { assert_eq!(audio_gain_from_env(), 0.0); }); with_var("LESAVKA_AUDIO_GAIN", Some("nope"), || { assert_eq!(audio_gain_from_env(), 2.0); }); } #[test] fn audio_gain_control_reads_first_token_and_clamps() { let dir = tempdir().expect("tempdir"); let path = dir.path().join("gain.control"); fs::write(&path, "4.500 nonce\n").expect("write gain"); assert_eq!(read_audio_gain_control(&path), Some(4.5)); fs::write(&path, "20.0 nonce\n").expect("write clamped gain"); assert_eq!(read_audio_gain_control(&path), Some(8.0)); fs::write(&path, "bad nonce\n").expect("write invalid gain"); assert_eq!(read_audio_gain_control(&path), None); } }