From 679a744ec81387ac0789a5f46d06c51e3658e709 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 19 May 2026 10:39:04 -0300 Subject: [PATCH] test(lesavka): cover input routing and synthetic uplink helpers --- client/src/input/tests/inputs.rs | 172 +++++++++++++++- server/src/bin/lesavka-synthetic-uplink.rs | 44 +--- .../src/bin/lesavka_synthetic_uplink/tests.rs | 194 ++++++++++++++++++ 3 files changed, 367 insertions(+), 43 deletions(-) create mode 100644 server/src/bin/lesavka_synthetic_uplink/tests.rs diff --git a/client/src/input/tests/inputs.rs b/client/src/input/tests/inputs.rs index 2a636e3..d4cd89a 100644 --- a/client/src/input/tests/inputs.rs +++ b/client/src/input/tests/inputs.rs @@ -1,5 +1,18 @@ -use super::parse_quick_toggle_key; +use super::{ + InputAggregator, input_device_override_from_env, matches_selected_input_device, + pending_release_timeout_from_env, quick_toggle_debounce_from_env, + remote_failsafe_timeout_from_env, update_shadow_pressed_keys, +}; use evdev::KeyCode; +use serial_test::serial; +use std::{ + collections::HashSet, + path::Path, + sync::atomic::Ordering, + time::{Duration, Instant}, +}; + +use super::parse_quick_toggle_key; #[test] fn parse_quick_toggle_key_supports_letters_digits_and_function_keys() { @@ -28,3 +41,160 @@ fn parse_quick_toggle_key_can_disable_or_fall_back() { Some(KeyCode::KEY_PAUSE) ); } + +#[test] +#[serial] +fn runtime_timeouts_apply_safe_defaults_and_floors() { + temp_env::with_vars( + [ + ("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", None::<&str>), + ("LESAVKA_INPUT_RELEASE_TIMEOUT_MS", None::<&str>), + ("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", None::<&str>), + ("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", None::<&str>), + ], + || { + assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(350)); + assert_eq!( + pending_release_timeout_from_env(), + Duration::from_millis(750) + ); + assert_eq!(remote_failsafe_timeout_from_env(), Duration::ZERO); + }, + ); + + temp_env::with_vars( + [ + ("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("1")), + ("LESAVKA_INPUT_RELEASE_TIMEOUT_MS", Some("1")), + ("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("250")), + ("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", None::<&str>), + ], + || { + assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(50)); + assert_eq!( + pending_release_timeout_from_env(), + Duration::from_millis(100) + ); + assert_eq!( + remote_failsafe_timeout_from_env(), + Duration::from_millis(250) + ); + }, + ); + + temp_env::with_vars( + [ + ("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("250")), + ("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("2")), + ], + || { + assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_secs(2)); + }, + ); +} + +#[test] +#[serial] +fn input_device_overrides_ignore_empty_and_all_values() { + temp_env::with_var("LESAVKA_KEYBOARD_DEVICE", Some("/dev/input/event4"), || { + assert_eq!( + input_device_override_from_env("LESAVKA_KEYBOARD_DEVICE").as_deref(), + Some("/dev/input/event4") + ); + }); + + for raw in ["", " ", "all", "ALL"] { + temp_env::with_var("LESAVKA_KEYBOARD_DEVICE", Some(raw), || { + assert_eq!( + input_device_override_from_env("LESAVKA_KEYBOARD_DEVICE"), + None + ); + }); + } +} + +#[test] +fn selected_input_device_matches_exact_paths_or_all_devices() { + let event_path = Path::new("/dev/input/event7"); + + assert!(matches_selected_input_device(event_path, None)); + assert!(matches_selected_input_device( + event_path, + Some("/dev/input/event7") + )); + assert!(!matches_selected_input_device( + event_path, + Some("/dev/input/event8") + )); +} + +#[test] +fn shadow_pressed_keys_tracks_press_hold_and_release() { + let mut pressed = HashSet::new(); + + update_shadow_pressed_keys(&mut pressed, KeyCode::KEY_A, 1); + update_shadow_pressed_keys(&mut pressed, KeyCode::KEY_B, 2); + update_shadow_pressed_keys(&mut pressed, KeyCode::KEY_C, -1); + assert!(pressed.contains(&KeyCode::KEY_A)); + assert!(pressed.contains(&KeyCode::KEY_B)); + assert!(!pressed.contains(&KeyCode::KEY_C)); + + update_shadow_pressed_keys(&mut pressed, KeyCode::KEY_A, 0); + assert!(!pressed.contains(&KeyCode::KEY_A)); +} + +#[test] +#[serial] +fn input_aggregator_respects_capture_boot_and_failsafe_state() { + let (kbd_tx, _) = tokio::sync::broadcast::channel(4); + let (mou_tx, _) = tokio::sync::broadcast::channel(4); + + temp_env::with_vars( + [ + ("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("500")), + ("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", None::<&str>), + ("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("50")), + ("LESAVKA_INPUT_RELEASE_TIMEOUT_MS", Some("100")), + ], + || { + let mut aggregator = + InputAggregator::new_with_capture_mode(false, kbd_tx, mou_tx, None, true); + + assert!(!aggregator.released); + assert!(aggregator.remote_capture_active()); + assert!( + aggregator + .remote_capture_enabled_handle() + .load(Ordering::Relaxed) + ); + assert_eq!(aggregator.quick_toggle_debounce, Duration::from_millis(50)); + assert_eq!( + aggregator.pending_release_timeout, + Duration::from_millis(100) + ); + assert_eq!( + aggregator.remote_failsafe_timeout, + Duration::from_millis(500) + ); + + aggregator.remote_failsafe_started_at = + Some(Instant::now() - Duration::from_millis(750)); + assert!(aggregator.remote_failsafe_expired()); + + aggregator.begin_local_release(); + assert!(!aggregator.remote_capture_active()); + assert!(aggregator.pending_release); + aggregator.pending_release_started_at = + Some(Instant::now() - Duration::from_millis(150)); + assert!(aggregator.pending_release_timed_out()); + + aggregator.finish_local_release(false); + assert!(aggregator.released); + assert!(!aggregator.pending_release); + + aggregator.enable_remote_capture(); + assert!(!aggregator.released); + assert!(aggregator.remote_capture_active()); + }, + ); +} diff --git a/server/src/bin/lesavka-synthetic-uplink.rs b/server/src/bin/lesavka-synthetic-uplink.rs index 571a4dd..308d555 100755 --- a/server/src/bin/lesavka-synthetic-uplink.rs +++ b/server/src/bin/lesavka-synthetic-uplink.rs @@ -444,45 +444,5 @@ async fn main() -> Result<()> { include!("lesavka_synthetic_uplink/support.rs"); #[cfg(test)] -mod tests { - use super::*; - - fn test_args(width: usize, height: usize, fps: u32) -> Args { - Args { - server: DEFAULT_SERVER.to_string(), - width, - height, - fps, - duration: Duration::from_secs(1), - jpeg_quality: DEFAULT_JPEG_QUALITY, - session_id: 1, - artifact_dir: None, - print_every: 0, - max_frame_bytes: 232_106, - tls_ca: None, - tls_client_cert: None, - tls_client_key: None, - tls_domain: None, - } - } - - #[test] - fn synthetic_frames_fit_safe_720p_and_1080p_isochronous_budget() { - for (width, height, fps) in [(1280, 720, 30), (1920, 1080, 30)] { - let args = test_args(width, height, fps); - let mut encoder = MjpegEncoder::new(&args).expect("synthetic encoder"); - for sequence in [0, 1, 30, 120, 300] { - let encoded = encoder.encode(sequence).expect("encode frame"); - assert!( - encoded.len() <= args.max_frame_bytes, - "{}x{}@{} synthetic frame {sequence} encoded to {} bytes, above {}", - width, - height, - fps, - encoded.len(), - args.max_frame_bytes - ); - } - } - } -} +#[path = "lesavka_synthetic_uplink/tests.rs"] +mod tests; diff --git a/server/src/bin/lesavka_synthetic_uplink/tests.rs b/server/src/bin/lesavka_synthetic_uplink/tests.rs new file mode 100644 index 0000000..9b1c587 --- /dev/null +++ b/server/src/bin/lesavka_synthetic_uplink/tests.rs @@ -0,0 +1,194 @@ +use super::*; +use serial_test::serial; + +fn test_args(width: usize, height: usize, fps: u32) -> Args { + Args { + server: DEFAULT_SERVER.to_string(), + width, + height, + fps, + duration: Duration::from_secs(1), + jpeg_quality: DEFAULT_JPEG_QUALITY, + session_id: 1, + artifact_dir: None, + print_every: 0, + max_frame_bytes: 232_106, + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + tls_domain: None, + } +} + +#[test] +fn args_frame_timing_rounds_up_short_runs() { + let mut args = test_args(640, 360, 30); + args.duration = Duration::from_millis(1_050); + + assert_eq!(args.frame_step_us(), 33_333); + assert_eq!(args.total_frames(), 32); + + args.duration = Duration::ZERO; + assert_eq!(args.total_frames(), 1); +} + +#[test] +fn encode_stats_track_bounds_mean_and_oversize_frames() { + let mut stats = EncodeStats::default(); + + assert_eq!(stats.mean_bytes(), 0); + + stats.record(100, 150); + stats.record_sent(); + stats.record(200, 150); + stats.record_sent(); + + assert_eq!(stats.frames, 2); + assert_eq!(stats.sent_frames, 2); + assert_eq!(stats.min_bytes, 100); + assert_eq!(stats.max_bytes, 200); + assert_eq!(stats.mean_bytes(), 150); + assert_eq!(stats.oversize_frames, 1); +} + +#[test] +fn synthetic_bundle_preserves_video_audio_and_session_metadata() { + let args = test_args(320, 180, 60); + let bundle = synthetic_bundle(&args, 7, 116_667, vec![1, 2, 3, 4]); + let video = bundle.video.as_ref().expect("video packet"); + let audio = bundle.audio.first().expect("audio packet"); + + assert_eq!(bundle.session_id, 1); + assert_eq!(bundle.seq, 7); + assert_eq!(bundle.capture_start_us, 116_667); + assert_eq!(bundle.video_width, 320); + assert_eq!(bundle.video_height, 180); + assert_eq!(bundle.video_fps, 60); + assert_eq!(video.seq, 7); + assert_eq!(video.pts, 116_667); + assert_eq!(video.data, vec![1, 2, 3, 4]); + assert_eq!(audio.seq, 7); + assert_eq!(audio.sample_rate, DEFAULT_SAMPLE_RATE); + assert_eq!(audio.channels, DEFAULT_CHANNELS); + assert_eq!(audio.frame_duration_us, args.frame_step_us() as u32); + assert_eq!(audio.data.len(), silence_pcm(args.frame_step_us()).len()); +} + +#[test] +fn synthetic_frame_helpers_handle_markers_bounds_and_silence() { + assert_eq!(marker_cell(320, 240), 6); + assert_eq!(marker_cell(3840, 2160), 16); + assert_eq!(silence_pcm(0).len(), 4); + assert_eq!(silence_pcm(20_000).len(), 3_840); + + let tiny = synthetic_rgb_frame(3, 2, 42); + assert_eq!(tiny.len(), 18); + + let mut frame = vec![0_u8; 6 * 4 * 3]; + fill_rect(&mut frame, 6, 4, 2, 8, 8, 77); + assert_eq!(&frame[(2 * 6 + 4) * 3..(2 * 6 + 5) * 3], &[77, 77, 77]); + assert_eq!(&frame[(3 * 6 + 5) * 3..(3 * 6 + 6) * 3], &[77, 77, 77]); +} + +#[test] +fn parse_mode_and_duration_helpers_cover_valid_and_invalid_inputs() { + assert_eq!(parse_mode("1920x1080@30").unwrap(), (1920, 1080, 30)); + assert!(parse_mode("1920x1080").is_err()); + assert!(parse_mode("1920@30").is_err()); + + assert_eq!( + duration_mul(Duration::from_millis(33), 3), + Duration::from_millis(99) + ); + assert_eq!( + duration_mul(Duration::from_secs(u64::MAX), 2), + Duration::from_nanos(u64::MAX) + ); +} + +#[test] +fn uri_and_tls_helpers_identify_https_and_host_names() { + assert!(is_https(" https://example.test:8443/path")); + assert!(!is_https("http://example.test")); + assert_eq!( + host_from_uri("https://user@example.test:8443/path").as_deref(), + Some("example.test") + ); + assert_eq!( + host_from_uri("https://[fd00::1]:50051").as_deref(), + Some("fd00::1") + ); + assert_eq!(host_from_uri("not-a-uri"), None); + + let mut args = test_args(320, 180, 30); + args.server = "https://relay.example.test:50051".to_string(); + args.tls_ca = None; + let err = client_tls_config(&args).expect_err("missing CA should fail"); + assert!(err.to_string().contains("--tls-ca")); +} + +#[test] +#[serial] +fn env_path_and_default_pki_path_ignore_empty_values() { + temp_env::with_vars( + [ + ("LESAVKA_SYNTHETIC_TEST_PATH", Some("/tmp/lesavka-test")), + ("HOME", Some("/home/tester")), + ], + || { + assert_eq!( + env_path("LESAVKA_SYNTHETIC_TEST_PATH").as_deref(), + Some(PathBuf::from("/tmp/lesavka-test").as_path()) + ); + assert_eq!( + default_pki_path("ca.crt").as_deref(), + Some(PathBuf::from("/home/tester/.config/lesavka/pki/ca.crt").as_path()) + ); + }, + ); + + temp_env::with_var("LESAVKA_SYNTHETIC_TEST_PATH", Some(""), || { + assert_eq!(env_path("LESAVKA_SYNTHETIC_TEST_PATH"), None); + }); +} + +#[test] +#[serial] +fn env_usize_and_summary_json_handle_empty_stats() { + temp_env::with_var("LESAVKA_SYNTHETIC_MAX_FRAME_BYTES", Some("2048"), || { + assert_eq!(env_usize("LESAVKA_SYNTHETIC_MAX_FRAME_BYTES"), Some(2048)); + }); + temp_env::with_var("LESAVKA_SYNTHETIC_MAX_FRAME_BYTES", Some("nope"), || { + assert_eq!(env_usize("LESAVKA_SYNTHETIC_MAX_FRAME_BYTES"), None); + }); + + let mut args = test_args(320, 180, 30); + args.server = "https://relay.example.test:50051".to_string(); + args.duration = Duration::from_millis(1500); + let summary = args_summary_json(&args, None, Some("complete")); + + assert!(summary.contains("\"tls\":true")); + assert!(summary.contains("\"encoded_frames\":0")); + assert!(summary.contains("\"encoded_min_bytes\":null")); + assert!(summary.contains("\"exit_reason\":\"complete\"")); +} + +#[test] +fn synthetic_frames_fit_safe_720p_and_1080p_isochronous_budget() { + for (width, height, fps) in [(1280, 720, 30), (1920, 1080, 30)] { + let args = test_args(width, height, fps); + let mut encoder = MjpegEncoder::new(&args).expect("synthetic encoder"); + for sequence in [0, 1, 30, 120, 300] { + let encoded = encoder.encode(sequence).expect("encode frame"); + assert!( + encoded.len() <= args.max_frame_bytes, + "{}x{}@{} synthetic frame {sequence} encoded to {} bytes, above {}", + width, + height, + fps, + encoded.len(), + args.max_frame_bytes + ); + } + } +}