lesavka/server/src/tests/runtime_support.rs

332 lines
10 KiB
Rust
Raw Normal View History

use super::{
allow_gadget_cycle, detect_uac_card_candidates, external_uvc_helper_owns_gadget, init_tracing,
next_stream_id, open_ear_with_retry, open_hid_if_ready, open_with_retry,
parse_uac_named_card_candidates, parse_uac_numeric_card_ids, parse_uac_pcm_candidates,
preferred_uac_device_candidates, push_audio_candidate, push_audio_candidate_family,
should_recover_hid_error, write_hid_report,
};
use serial_test::serial;
use std::collections::BTreeSet;
use std::sync::Arc;
use temp_env::with_var;
use tempfile::NamedTempFile;
use tempfile::tempdir;
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
#[test]
#[serial]
fn allow_gadget_cycle_tracks_env_presence() {
with_var("LESAVKA_ALLOW_GADGET_CYCLE", None::<&str>, || {
assert!(!allow_gadget_cycle());
});
with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || {
assert!(allow_gadget_cycle());
});
}
#[test]
#[serial]
fn allow_gadget_cycle_defers_to_external_uvc_owner() {
with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || {
with_var("LESAVKA_UVC_EXTERNAL", Some("1"), || {
with_var("LESAVKA_DISABLE_UVC", None::<&str>, || {
with_var(
"LESAVKA_ALLOW_EXTERNAL_UVC_GADGET_CYCLE",
None::<&str>,
|| {
assert!(external_uvc_helper_owns_gadget());
assert!(
!allow_gadget_cycle(),
"server must not reset the gadget while external UVC owns it"
);
},
);
});
});
});
}
#[test]
#[serial]
fn allow_gadget_cycle_can_be_forced_with_external_uvc_owner() {
with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || {
with_var("LESAVKA_UVC_EXTERNAL", Some("1"), || {
with_var("LESAVKA_ALLOW_EXTERNAL_UVC_GADGET_CYCLE", Some("1"), || {
assert!(allow_gadget_cycle());
});
});
});
}
#[test]
fn should_recover_hid_error_matches_transport_failures() {
assert!(should_recover_hid_error(Some(libc::ENOTCONN)));
assert!(should_recover_hid_error(Some(libc::ESHUTDOWN)));
assert!(should_recover_hid_error(Some(libc::EPIPE)));
assert!(!should_recover_hid_error(Some(libc::EAGAIN)));
assert!(!should_recover_hid_error(None));
}
#[test]
fn next_stream_id_monotonically_increments() {
let first = next_stream_id();
let second = next_stream_id();
assert!(second > first);
}
#[test]
fn preferred_uac_device_candidates_keeps_custom_override_only() {
let candidates = preferred_uac_device_candidates("hw:7,0");
assert_eq!(candidates, vec!["hw:7,0", "plughw:7,0"]);
}
#[test]
fn preferred_uac_device_candidates_handles_blank_and_plughw_overrides() {
assert!(preferred_uac_device_candidates(" ").is_empty());
assert_eq!(
preferred_uac_device_candidates(" plughw:8,2 "),
vec!["plughw:8,2", "hw:8,2"]
);
}
#[test]
fn preferred_uac_device_candidates_expands_known_aliases() {
let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0");
assert!(candidates.iter().any(|value| value == "hw:UAC2Gadget,0"));
assert!(
candidates
.iter()
.any(|value| value == "plughw:UAC2Gadget,0")
);
assert!(candidates.iter().any(|value| value == "hw:UAC2_Gadget,0"));
assert!(candidates.iter().any(|value| value == "hw:Composite,0"));
}
#[test]
fn audio_candidate_helpers_dedupe_and_pair_hw_plughw_forms() {
let mut out = Vec::new();
let mut seen = BTreeSet::new();
push_audio_candidate(&mut out, &mut seen, " ");
push_audio_candidate(&mut out, &mut seen, " hw:9,0 ");
push_audio_candidate(&mut out, &mut seen, "hw:9,0");
push_audio_candidate_family(&mut out, &mut seen, "plughw:9,0");
push_audio_candidate_family(&mut out, &mut seen, " ");
assert_eq!(out, vec!["hw:9,0".to_string(), "plughw:9,0".to_string()]);
}
#[test]
#[cfg(coverage)]
#[serial]
fn preferred_uac_candidates_include_detected_cards_before_alias_fallbacks() {
temp_env::with_vars(
[
(
"LESAVKA_TEST_ASOUND_CARDS",
Some(
" 7 [DetectedGadget ]: USB-Audio - UAC2 Gadget
\
8 [LesavkaAudio ]: USB-Audio - Lesavka
",
),
),
(
"LESAVKA_TEST_ASOUND_PCM",
Some(
"07-03: USB Audio : USB Audio : playback 1 : capture 1
",
),
),
],
|| {
let candidates = preferred_uac_device_candidates("hw:UAC2Gadget,0");
2026-04-24 17:44:11 -03:00
assert_eq!(candidates.first().map(String::as_str), Some("hw:7,3"));
assert!(candidates.iter().any(|value| value == "plughw:7,3"));
assert!(
candidates
.iter()
.any(|value| value == "hw:DetectedGadget,0")
);
assert!(candidates.iter().any(|value| value == "hw:LesavkaAudio,0"));
assert!(candidates.iter().any(|value| value == "hw:7,3"));
let preferred_index = candidates
.iter()
.position(|value| value == "hw:UAC2Gadget,0")
.expect("preferred alias present");
let numeric_index = candidates
.iter()
.position(|value| value == "hw:7,3")
.expect("numeric candidate present");
let detected_index = candidates
.iter()
.position(|value| value == "hw:DetectedGadget,0")
.expect("detected candidate present");
assert!(numeric_index < detected_index);
assert!(detected_index < preferred_index);
},
);
}
#[test]
fn detect_uac_card_candidates_returns_hw_names_only() {
let live = detect_uac_card_candidates();
assert!(live.iter().all(|value| value.starts_with("hw:")));
}
#[test]
fn parse_uac_card_helpers_collect_named_and_numeric_candidates() {
let cards = "\
0 [PCH ]: HDA-Intel - HDA Intel PCH\n\
2 [UAC2Gadget ]: USB-Audio - UAC2Gadget\n\
Lesavka USB Audio\n";
assert_eq!(
parse_uac_named_card_candidates(cards),
vec!["hw:UAC2Gadget,0"]
);
assert!(
parse_uac_numeric_card_ids(cards).contains("2"),
"expected numeric card index for the gadget card"
);
}
#[test]
fn parse_uac_card_helpers_ignore_malformed_candidates() {
let cards = "\
XX [BrokenGadget ]: USB-Audio - UAC2 Gadget\n\
03 NoBracketGadget : USB-Audio - Lesavka\n\
04 [ ]: USB-Audio - Composite\n\
05 [Composite ]: USB-Audio - Composite\n";
assert_eq!(
parse_uac_named_card_candidates(cards),
vec!["hw:BrokenGadget,0", "hw:Composite,0"]
);
assert_eq!(
parse_uac_numeric_card_ids(cards),
BTreeSet::from(["03".to_string(), "04".to_string(), "05".to_string()])
);
}
#[test]
fn parse_uac_pcm_candidates_expands_all_matching_device_indexes() {
let pcm = "\
00-00: PCH device : playback 1 : capture 1\n\
02-00: USB Audio : USB Audio : playback 1 : capture 1\n\
02-01: USB Audio #1 : USB Audio #1 : playback 1 : capture 1\n";
let ids = BTreeSet::from(["2".to_string()]);
assert_eq!(
parse_uac_pcm_candidates(pcm, &ids),
vec!["hw:2,0", "hw:2,1"]
);
}
#[test]
fn parse_uac_pcm_candidates_normalizes_zeroes_and_skips_non_matching_cards() {
let pcm = "\
00-00: Zero card : playback 1 : capture 1\n\
09-03: Other card : playback 1 : capture 1\n\
bad line without separator\n";
let ids = BTreeSet::from(["0".to_string()]);
assert_eq!(parse_uac_pcm_candidates(pcm, &ids), vec!["hw:0,0"]);
}
#[tokio::test]
#[serial]
async fn open_with_retry_opens_existing_file() {
let tmp = NamedTempFile::new().expect("temp file");
let mut file = open_with_retry(tmp.path().to_str().unwrap())
.await
.expect("open should succeed");
file.write_all(b"ok").await.expect("write temp file");
file.sync_all().await.expect("sync temp file");
assert_eq!(
tokio::fs::read(tmp.path()).await.expect("read temp file"),
b"ok"
);
}
#[test]
fn init_tracing_returns_a_guard_under_coverage() {
let _guard = init_tracing().expect("coverage tracing guard");
}
#[tokio::test]
#[serial]
async fn hid_open_helpers_return_contextual_errors_for_bad_paths() {
let dir = tempdir().expect("tempdir");
let err = open_with_retry(dir.path().to_str().unwrap())
.await
.expect_err("directory should not open as HID file");
assert!(format!("{err:#}").contains("opening"));
let err = open_hid_if_ready(dir.path().to_str().unwrap())
.await
.expect_err("directory should be a hard open error");
assert!(format!("{err:#}").contains("opening"));
}
#[tokio::test]
#[serial]
async fn write_hid_report_writes_bytes() {
let tmp = NamedTempFile::new().expect("temp file");
let file = tokio::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(tmp.path())
.await
.expect("open temp file");
let shared = Arc::new(Mutex::new(Some(file)));
write_hid_report(&shared, tmp.path().to_str().unwrap(), &[1, 2, 3, 4])
.await
.expect("write succeeds");
let contents = tokio::fs::read(tmp.path())
.await
.expect("read back temp file");
assert_eq!(&contents, &[1, 2, 3, 4]);
}
#[tokio::test]
#[serial]
async fn write_hid_report_opens_lazily_when_handle_is_empty() {
let tmp = NamedTempFile::new().expect("temp file");
let shared = Arc::new(Mutex::new(None));
write_hid_report(&shared, tmp.path().to_str().unwrap(), &[9, 8])
.await
.expect("lazy write succeeds");
let contents = tokio::fs::read(tmp.path())
.await
.expect("read back temp file");
assert_eq!(&contents, &[9, 8]);
}
#[test]
#[serial]
fn open_ear_with_retry_reports_bad_capture_device() {
let err = temp_env::with_vars(
[
("LESAVKA_AUDIO_INIT_ATTEMPTS", Some("1")),
("LESAVKA_AUDIO_INIT_DELAY_MS", Some("0")),
],
|| {
let runtime = tokio::runtime::Runtime::new().expect("test runtime");
match runtime.block_on(open_ear_with_retry(
"hw:DefinitelyMissingLesavkaDevice,99",
99,
)) {
Ok(_) => panic!("missing ALSA source should fail"),
Err(err) => err,
}
},
);
assert!(!format!("{err:#}").is_empty());
}