214 lines
7.9 KiB
Rust
214 lines
7.9 KiB
Rust
//! Include-based coverage for microphone source-selection helpers.
|
|
//!
|
|
//! Scope: include `client/src/input/microphone.rs` and exercise Pulse source
|
|
//! parsing + fallback behavior without requiring a live audio stack.
|
|
//! Targets: `client/src/input/microphone.rs`.
|
|
//! Why: source selection regressions should be caught with deterministic tests.
|
|
|
|
#[allow(warnings)]
|
|
mod live_capture_clock {
|
|
include!("support/live_capture_clock_shim.rs");
|
|
}
|
|
|
|
#[allow(warnings)]
|
|
mod microphone_include_contract {
|
|
include!(env!("LESAVKA_CLIENT_MICROPHONE_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_command(name: &str, script_body: &str, f: impl FnOnce()) {
|
|
let dir = tempdir().expect("tempdir");
|
|
write_executable(dir.path(), name, 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);
|
|
}
|
|
|
|
fn with_fake_pactl(script_body: &str, f: impl FnOnce()) {
|
|
with_fake_command("pactl", script_body, f);
|
|
}
|
|
|
|
fn with_fake_pw_dump(script_body: &str, f: impl FnOnce()) {
|
|
with_fake_command("pw-dump", script_body, f);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn pulse_source_by_substr_matches_expected_device_name() {
|
|
let script = r#"#!/usr/bin/env sh
|
|
if [ "$1" = "list" ] && [ "$2" = "short" ] && [ "$3" = "sources" ]; then
|
|
echo "0 alsa_input.pci.monitor module-alsa-card.c s16le 2ch 48000Hz RUNNING"
|
|
echo "1 alsa_input.usb-Mic_1234-00.analog-stereo module-alsa-card.c s16le 2ch 48000Hz IDLE"
|
|
exit 0
|
|
fi
|
|
exit 0
|
|
"#;
|
|
with_fake_pactl(script, || {
|
|
let src =
|
|
MicrophoneCapture::pulse_source_by_substr("Mic_1234").expect("matching source");
|
|
assert_eq!(src, "alsa_input.usb-Mic_1234-00.analog-stereo");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn pulse_source_desc_formats_selected_non_monitor_source() {
|
|
let script = r#"#!/usr/bin/env sh
|
|
if [ "$1" = "list" ] && [ "$2" = "short" ] && [ "$3" = "sources" ]; then
|
|
echo "0 alsa_input.pci.monitor module-alsa-card.c s16le 2ch 48000Hz RUNNING"
|
|
echo "1 alsa_input.usb-DeskMic_5678-00.analog-stereo module-alsa-card.c s16le 2ch 48000Hz IDLE"
|
|
exit 0
|
|
fi
|
|
exit 0
|
|
"#;
|
|
with_fake_pactl(script, || {
|
|
let source =
|
|
MicrophoneCapture::pulse_source_by_substr("DeskMic_5678").expect("matching source");
|
|
let desc = MicrophoneCapture::pulse_source_desc(Some(&source));
|
|
assert!(
|
|
desc.contains("pulsesrc device=alsa_input.usb-DeskMic_5678-00.analog-stereo"),
|
|
"expected escaped non-monitor source argument: {desc}"
|
|
);
|
|
assert!(desc.contains("buffer-time=40000"));
|
|
assert!(desc.contains("latency-time=10000"));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn pulse_source_by_substr_returns_none_when_pactl_is_unavailable() {
|
|
with_var("PATH", Some("/definitely/missing/path"), || {
|
|
assert!(MicrophoneCapture::pulse_source_by_substr("anything").is_none());
|
|
assert_eq!(
|
|
MicrophoneCapture::pulse_source_desc(None),
|
|
"pulsesrc do-timestamp=true buffer-time=40000 latency-time=10000"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn pulse_source_desc_allows_low_latency_overrides_with_safe_clamp() {
|
|
with_var("LESAVKA_MIC_PULSE_BUFFER_TIME_US", Some("20000"), || {
|
|
with_var("LESAVKA_MIC_PULSE_LATENCY_TIME_US", Some("50000"), || {
|
|
assert_eq!(
|
|
MicrophoneCapture::pulse_source_desc(None),
|
|
"pulsesrc do-timestamp=true buffer-time=20000 latency-time=20000"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn pipewire_source_desc_formats_selected_and_default_sources() {
|
|
let selected = MicrophoneCapture::pipewire_source_desc(Some("alsa input/Desk Mic"));
|
|
assert!(
|
|
selected.contains("pipewiresrc target-object='alsa input/Desk Mic'"),
|
|
"expected shell-escaped PipeWire target: {selected}"
|
|
);
|
|
assert_eq!(
|
|
MicrophoneCapture::pipewire_source_desc(Some(" ")),
|
|
"pipewiresrc do-timestamp=true"
|
|
);
|
|
assert_eq!(
|
|
MicrophoneCapture::pipewire_source_desc(None),
|
|
"pipewiresrc do-timestamp=true"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn pipewire_source_by_substr_prefers_audio_sources_and_skips_monitors() {
|
|
let script = r#"#!/usr/bin/env sh
|
|
cat <<'JSON'
|
|
[
|
|
{"info":{"props":{"media.class":"Audio/Sink","node.name":"alsa_output.usb-headphones"}}},
|
|
{"info":{"props":{"media.class":"Audio/Source","node.name":"alsa_input.usb-DeskMic.monitor"}}},
|
|
{"info":{"props":{"media.class":"Audio/Source","node.name":"alsa_input.usb-DeskMic"}}},
|
|
{"info":{"props":{"media.class":"Audio/Source","node.nick":"Fallback Nick Mic"}}}
|
|
]
|
|
JSON
|
|
"#;
|
|
with_fake_pw_dump(script, || {
|
|
assert_eq!(
|
|
MicrophoneCapture::pipewire_source_by_substr("DeskMic").as_deref(),
|
|
Some("alsa_input.usb-DeskMic")
|
|
);
|
|
assert_eq!(
|
|
MicrophoneCapture::pipewire_source_by_substr("Fallback Nick").as_deref(),
|
|
Some("Fallback Nick Mic")
|
|
);
|
|
assert!(MicrophoneCapture::pipewire_source_by_substr("missing").is_none());
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn default_source_desc_selects_a_valid_gstreamer_source_description() {
|
|
gst::init().ok();
|
|
let desc = MicrophoneCapture::default_source_desc();
|
|
assert!(
|
|
desc == "pipewiresrc do-timestamp=true"
|
|
|| desc == "pulsesrc do-timestamp=true buffer-time=40000 latency-time=10000",
|
|
"default source should stay a simple PipeWire/Pulse source: {desc}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn mic_gain_env_defaults_and_clamps_for_uplink_gain() {
|
|
with_var("LESAVKA_MIC_GAIN", None::<&str>, || {
|
|
assert_eq!(mic_gain_from_env(), 1.0);
|
|
});
|
|
with_var("LESAVKA_MIC_GAIN", Some("2.75"), || {
|
|
assert_eq!(mic_gain_from_env(), 2.75);
|
|
});
|
|
with_var("LESAVKA_MIC_GAIN", Some("99"), || {
|
|
assert_eq!(mic_gain_from_env(), 4.0);
|
|
});
|
|
with_var("LESAVKA_MIC_GAIN", Some("-1"), || {
|
|
assert_eq!(mic_gain_from_env(), 0.0);
|
|
});
|
|
with_var("LESAVKA_MIC_GAIN", Some("bad"), || {
|
|
assert_eq!(mic_gain_from_env(), 1.0);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn microphone_pipeline_desc_adds_level_tap_only_when_requested() {
|
|
let with_tap = microphone_pipeline_desc("audiotestsrc is-live=true", 2.5, true);
|
|
assert!(
|
|
with_tap
|
|
.contains("audiotestsrc is-live=true ! audioconvert ! audioresample ! audio/x-raw")
|
|
);
|
|
assert!(!with_tap.contains("audiotestsrc is-live=true ! audio/x-raw"));
|
|
assert!(with_tap.contains("tee name=t"));
|
|
assert!(with_tap.contains("appsink name=level_sink"));
|
|
assert!(with_tap.contains("volume name=mic_input_gain volume=2.500"));
|
|
|
|
let without_tap = microphone_pipeline_desc("audiotestsrc is-live=true", 1.0, false);
|
|
assert!(!without_tap.contains("level_sink"));
|
|
assert!(
|
|
without_tap
|
|
.contains("queue max-size-buffers=8 max-size-time=80000000 leaky=downstream")
|
|
);
|
|
assert!(without_tap.contains("appsink name=asink emit-signals=true max-buffers=8"));
|
|
}
|
|
}
|