lesavka/testing/tests/client_microphone_include_contract.rs

330 lines
11 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 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}"
);
});
}
#[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"
);
});
}
#[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",
"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 mic_gain_control_reads_first_token_and_clamps() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("mic-gain.control");
fs::write(&path, "3.250 nonce\n").expect("write gain");
assert_eq!(read_mic_gain_control(&path), Some(3.25));
fs::write(&path, "20.0 nonce\n").expect("write clamped gain");
assert_eq!(read_mic_gain_control(&path), Some(4.0));
fs::write(&path, "bad nonce\n").expect("write invalid gain");
assert_eq!(read_mic_gain_control(&path), None);
}
#[test]
#[serial]
fn mic_gain_control_returns_without_env() {
gst::init().ok();
let volume = gst::ElementFactory::make("volume")
.build()
.expect("volume element");
volume.set_property("volume", 1.75_f64);
with_var("LESAVKA_MIC_GAIN_CONTROL", None::<&str>, || {
maybe_spawn_mic_gain_control(volume.clone());
});
assert_eq!(volume.property::<f64>("volume"), 1.75);
}
#[test]
#[serial]
fn mic_gain_control_updates_volume_element_live() {
gst::init().ok();
let dir = tempdir().expect("tempdir");
let path = dir.path().join("mic-gain.control");
fs::write(&path, "2.500 nonce\n").expect("write gain");
let volume = gst::ElementFactory::make("volume")
.build()
.expect("volume element");
with_var(
"LESAVKA_MIC_GAIN_CONTROL",
Some(path.to_string_lossy().to_string()),
|| {
maybe_spawn_mic_gain_control(volume.clone());
for _ in 0..20 {
if (volume.property::<f64>("volume") - 2.5).abs() < 0.001 {
return;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
},
);
assert!(
(volume.property::<f64>("volume") - 2.5).abs() < 0.001,
"live mic gain control should update the GStreamer volume"
);
}
#[test]
fn pull_returns_none_for_empty_appsink() {
gst::init().ok();
let sink: gst_app::AppSink = gst::ElementFactory::make("appsink")
.build()
.expect("appsink")
.downcast::<gst_app::AppSink>()
.expect("appsink cast");
let cap = MicrophoneCapture {
pipeline: gst::Pipeline::new(),
sink,
};
assert!(
cap.pull().is_none(),
"empty appsink should produce no packet"
);
}
#[test]
fn pull_returns_packet_when_appsink_has_buffered_sample() {
gst::init().ok();
let pipeline = gst::Pipeline::new();
let src = gst::ElementFactory::make("appsrc")
.build()
.expect("appsrc")
.downcast::<gst_app::AppSrc>()
.expect("appsrc cast");
let sink = gst::ElementFactory::make("appsink")
.property("emit-signals", false)
.property("sync", false)
.build()
.expect("appsink")
.downcast::<gst_app::AppSink>()
.expect("appsink cast");
pipeline
.add_many([
src.upcast_ref::<gst::Element>(),
sink.upcast_ref::<gst::Element>(),
])
.expect("add appsrc/appsink");
src.link(&sink).expect("link appsrc->appsink");
pipeline.set_state(gst::State::Playing).ok();
let mut buf = gst::Buffer::from_slice(vec![1_u8, 2, 3, 4]);
buf.get_mut()
.expect("buffer mut")
.set_pts(Some(gst::ClockTime::from_useconds(321)));
src.push_buffer(buf).expect("push sample");
let cap = MicrophoneCapture { pipeline, sink };
let pkt = cap.pull().expect("audio packet");
assert_eq!(pkt.id, 0);
assert_eq!(pkt.pts, 321);
assert_eq!(pkt.data, vec![1, 2, 3, 4]);
}
#[test]
#[serial]
fn new_uses_requested_source_fragment_when_available() {
let script = r#"#!/usr/bin/env sh
if [ "$1" = "list" ] && [ "$2" = "short" ] && [ "$3" = "sources" ]; then
echo "1 alsa_input.usb-LavMic_abc-00.analog-stereo module-alsa-card.c s16le 2ch 48000Hz RUNNING"
exit 0
fi
exit 0
"#;
with_fake_pactl(script, || {
with_var("LESAVKA_MIC_SOURCE", Some("LavMic_abc"), || {
let result = MicrophoneCapture::new();
if let Err(err) = result {
assert!(!err.to_string().trim().is_empty());
}
});
});
}
#[test]
#[serial]
fn new_falls_back_to_default_source_when_requested_fragment_is_missing() {
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_777-00.analog-stereo module-alsa-card.c s16le 2ch 48000Hz IDLE"
exit 0
fi
exit 0
"#;
with_fake_pactl(script, || {
with_var("LESAVKA_MIC_SOURCE", Some("missing-fragment"), || {
let result = MicrophoneCapture::new();
if let Err(err) = result {
assert!(!err.to_string().trim().is_empty());
}
});
});
}
}