368 lines
13 KiB
Rust
368 lines
13 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]
|
|
fn downstream_audio_pipeline_can_tee_a_debug_aac_tap() {
|
|
let desc = audio_output_pipeline_desc("fakesink sync=false", 1.0, true);
|
|
assert!(desc.contains("tee name=t"));
|
|
assert!(desc.contains("filesink location=/tmp/lesavka-audio.aac"));
|
|
assert!(desc.contains("volume name=remote_audio_gain volume=1.000"));
|
|
}
|
|
|
|
#[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_control_sample_applies_only_changed_values() {
|
|
let _ = gst::init();
|
|
let volume = gst::ElementFactory::make("volume")
|
|
.build()
|
|
.expect("volume element");
|
|
let dir = tempdir().expect("tempdir");
|
|
let path = dir.path().join("gain.control");
|
|
let mut last_gain = None;
|
|
|
|
fs::write(&path, "1.5\n").expect("write gain");
|
|
assert_eq!(
|
|
apply_audio_gain_control_sample(&path, &volume, &mut last_gain),
|
|
Some(1.5)
|
|
);
|
|
assert_eq!(last_gain, Some(1.5));
|
|
assert_eq!(volume.property::<f64>("volume"), 1.5);
|
|
assert_eq!(
|
|
apply_audio_gain_control_sample(&path, &volume, &mut last_gain),
|
|
None
|
|
);
|
|
|
|
fs::write(&path, "2.25\n").expect("write changed gain");
|
|
assert_eq!(
|
|
apply_audio_gain_control_sample(&path, &volume, &mut last_gain),
|
|
Some(2.25)
|
|
);
|
|
assert_eq!(volume.property::<f64>("volume"), 2.25);
|
|
}
|
|
|
|
#[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);
|
|
|
|
let sinks = parse_pactl_short_sinks(
|
|
"1 default.sink module IDLE\n2 other.sink module IDLE\n",
|
|
Some("default.sink"),
|
|
);
|
|
assert_eq!(sinks[0], ("default.sink".to_string(), "IDLE".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn list_pw_sinks_uses_default_when_short_sink_listing_fails() {
|
|
let script = r#"#!/usr/bin/env sh
|
|
if [ "$1" = "info" ]; then
|
|
echo "Default Sink: fallback.default"
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "list" ]; then
|
|
exit 1
|
|
fi
|
|
exit 1
|
|
"#;
|
|
with_fake_pactl(script, || {
|
|
with_var("LESAVKA_AUDIO_SINK", None::<&str>, || {
|
|
assert_eq!(
|
|
list_pw_sinks(),
|
|
vec![("fallback.default".to_string(), "DEFAULT".to_string())]
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn live_audio_buffer_logs_periodic_packets_after_remote_gap() {
|
|
let _ = gst::init();
|
|
let timeline = std::sync::Mutex::new(AudioTimeline {
|
|
last_remote_pts_us: Some(1_000),
|
|
packets: 599,
|
|
});
|
|
let buffer = live_audio_buffer(
|
|
AudioPacket {
|
|
id: 0,
|
|
pts: 1_500,
|
|
data: vec![0xFF, 0xF1, 0x50, 0x80, 0x00, 0x1F, 0xFC],
|
|
},
|
|
&timeline,
|
|
);
|
|
assert_eq!(buffer.size(), 7);
|
|
let timeline = timeline.lock().expect("timeline");
|
|
assert_eq!(timeline.last_remote_pts_us, Some(1_500));
|
|
assert_eq!(timeline.packets, 600);
|
|
}
|
|
}
|