145 lines
5.1 KiB
Rust
145 lines
5.1 KiB
Rust
//! Integration coverage for server audio capture/sink plumbing.
|
|
//!
|
|
//! Scope: compile the split server audio module and exercise public audio
|
|
//! constructors/helpers across deterministic error and smoke paths.
|
|
//! Targets: `server/src/audio.rs` plus `server/src/audio/*`.
|
|
//! Why: audio pipeline setup is branchy and should stay stable without requiring
|
|
//! physical ALSA/UAC hardware in CI.
|
|
|
|
#[path = "../../server/src/audio.rs"]
|
|
#[allow(warnings)]
|
|
mod server_audio_contract;
|
|
|
|
mod tests {
|
|
use super::server_audio_contract::{ClipTap, Voice, ear, start_pipeline_or_reset};
|
|
#[cfg(coverage)]
|
|
use futures_util::StreamExt;
|
|
use gstreamer as gst;
|
|
use gstreamer::prelude::*;
|
|
use lesavka_common::lesavka::AudioPacket;
|
|
use serial_test::serial;
|
|
|
|
const AUDIO_SRC: &str = concat!(
|
|
include_str!("../../server/src/audio.rs"),
|
|
include_str!("../../server/src/audio/ear_capture.rs"),
|
|
include_str!("../../server/src/audio/voice_input.rs"),
|
|
);
|
|
|
|
fn source_index(needle: &str) -> usize {
|
|
AUDIO_SRC
|
|
.find(needle)
|
|
.unwrap_or_else(|| panic!("missing source marker: {needle}"))
|
|
}
|
|
|
|
#[test]
|
|
fn failed_pipeline_start_contract_resets_and_does_not_spawn_bus_first() {
|
|
assert!(AUDIO_SRC.contains("let _ = pipeline.set_state(gst::State::Null);"));
|
|
assert!(AUDIO_SRC.contains("impl Drop for Voice"));
|
|
assert!(
|
|
source_index("start_pipeline_or_reset(&pipeline, \"starting audio pipeline\")?")
|
|
< source_index("spawn_pipeline_bus_logger(bus, \"audio\"")
|
|
);
|
|
assert!(
|
|
source_index("start_pipeline_or_reset(&pipeline, \"starting voice pipeline\")?")
|
|
< source_index("spawn_pipeline_bus_logger(bus, \"voice\"")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn start_pipeline_or_reset_starts_empty_pipeline() {
|
|
let _ = gst::init();
|
|
let pipeline = gst::Pipeline::new();
|
|
start_pipeline_or_reset(&pipeline, "starting contract pipeline")
|
|
.expect("empty pipeline should enter playing");
|
|
assert_eq!(pipeline.current_state(), gst::State::Playing);
|
|
let _ = pipeline.set_state(gst::State::Null);
|
|
}
|
|
|
|
#[cfg(coverage)]
|
|
#[test]
|
|
#[serial]
|
|
fn start_pipeline_or_reset_forced_failure_resets_pipeline() {
|
|
let _ = gst::init();
|
|
let pipeline = gst::Pipeline::new();
|
|
temp_env::with_var("LESAVKA_TEST_FORCE_PIPELINE_START_ERROR", Some("1"), || {
|
|
let err = start_pipeline_or_reset(&pipeline, "starting forced contract pipeline")
|
|
.expect_err("forced coverage error");
|
|
assert!(err.to_string().contains("forced test failure"));
|
|
});
|
|
assert_eq!(pipeline.current_state(), gst::State::Null);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn ear_rejects_malformed_pipeline_device_string() {
|
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
let result = rt.block_on(ear("hw:UAC2Gadget,0\" ! broken-pipe", 0));
|
|
assert!(result.is_err(), "malformed device string should fail parse");
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn ear_missing_device_path_is_stable() {
|
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
let result = rt.block_on(ear("hw:DefinitelyMissingDevice,0", 0));
|
|
if let Err(err) = result {
|
|
assert!(!err.to_string().trim().is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn ear_existing_non_audio_node_reaches_runtime_paths() {
|
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
let result = rt.block_on(async {
|
|
tokio::time::timeout(std::time::Duration::from_millis(250), ear("/dev/null", 0)).await
|
|
});
|
|
if let Ok(Ok(stream)) = result {
|
|
drop(stream)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clip_tap_feed_flush_and_drop_are_stable() {
|
|
let mut tap = ClipTap::new("audio-contract", std::time::Duration::from_millis(1));
|
|
tap.feed(&[1, 2, 3, 4, 5]);
|
|
tap.feed(&vec![9u8; 300_000]);
|
|
tap.flush();
|
|
tap.flush(); // empty flush should be a no-op
|
|
drop(tap);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn voice_constructor_and_push_finish_are_stable() {
|
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
let result = rt.block_on(Voice::new("hw:DefinitelyMissingDevice,0"));
|
|
match result {
|
|
Ok(mut voice) => {
|
|
voice.push(&AudioPacket {
|
|
id: 0,
|
|
pts: 77,
|
|
data: vec![0xFF, 0xF1, 0x50, 0x80, 0x00, 0x1F, 0xFC],
|
|
});
|
|
voice.finish();
|
|
}
|
|
Err(err) => {
|
|
assert!(!err.to_string().trim().is_empty());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(coverage)]
|
|
#[test]
|
|
#[serial]
|
|
fn audio_stream_poll_next_is_stable_when_channel_is_closed() {
|
|
let rt = tokio::runtime::Runtime::new().expect("runtime");
|
|
let polled = rt.block_on(async {
|
|
let mut stream = ear("/dev/null", 0).await.expect("coverage ear stream");
|
|
stream.next().await
|
|
});
|
|
assert!(polled.is_none(), "closed stream should yield None");
|
|
}
|
|
}
|