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"); 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()); }