lesavka/testing/tests/client_output_audio_include_contract.rs

280 lines
9.8 KiB
Rust

//! 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);
}
#[test]
fn audio_gain_parsing_and_formatting_are_stable() {
assert_eq!(parse_audio_gain("3.5 ignored"), Some(3.5));
assert_eq!(parse_audio_gain("99"), Some(MAX_AUDIO_GAIN));
assert_eq!(parse_audio_gain("-1"), Some(0.0));
assert_eq!(parse_audio_gain("nan"), None);
assert_eq!(parse_audio_gain(""), None);
assert_eq!(format_audio_gain_for_gst(2.1256), "2.126");
}
#[test]
fn sink_override_escaping_and_pactl_ranking_are_stable() {
assert_eq!(normalize_sink_override("autoaudiosink"), "autoaudiosink");
assert_eq!(
normalize_sink_override("fakesink sync=false"),
"fakesink sync=false"
);
let normal = normalize_sink_override("alsa_output.pci");
assert!(normal.contains("pulsesink device=\"alsa_output.pci\""));
assert!(normal.contains("buffer-time=350000"));
let bluetooth = pulsesink_device_element("bluez_output.headset");
assert!(bluetooth.contains("buffer-time=750000"));
let escaped = pulsesink_device_element("sink\\\"name");
assert!(escaped.contains("sink\\\\\\\"name"));
assert_eq!(
parse_pactl_default_sink("Server: x\nDefault Sink: my.default \n"),
Some("my.default".to_string())
);
assert_eq!(parse_pactl_default_sink("Default Sink: \n"), None);
let sinks = parse_pactl_short_sinks(
"bad\n1 idle.sink module IDLE\n2 run.sink module RUNNING\n3 suspended.sink module SUSPENDED\n",
Some("missing.default"),
);
assert_eq!(
sinks[0],
("missing.default".to_string(), "DEFAULT".to_string())
);
assert_eq!(sinks[1], ("run.sink".to_string(), "RUNNING".to_string()));
assert_eq!(sink_state_rank("RUNNING"), 0);
assert_eq!(sink_state_rank("IDLE"), 1);
assert_eq!(sink_state_rank("SUSPENDED"), 2);
assert_eq!(sink_state_rank("UNKNOWN"), 3);
}
}