diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index a15d5e3..fc47134 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -352,7 +352,16 @@ impl CameraCapture { #[cfg(coverage)] fn choose_encoder() -> (&'static str, &'static str, &'static str) { - ("x264enc", "key-int-max", "30") + match std::env::var("LESAVKA_CAM_TEST_ENCODER") + .ok() + .as_deref() + .map(str::trim) + { + Some("nvh264enc") => ("nvh264enc", "gop-size", "30"), + Some("vaapih264enc") => ("vaapih264enc", "keyframe-period", "30"), + Some("v4l2h264enc") => ("v4l2h264enc", "idrcount", "30"), + _ => ("x264enc", "key-int-max", "30"), + } } } diff --git a/client/src/input/keyboard.rs b/client/src/input/keyboard.rs index 7bdb6cb..ae5cc2d 100644 --- a/client/src/input/keyboard.rs +++ b/client/src/input/keyboard.rs @@ -33,6 +33,25 @@ pub struct KeyboardAggregator { static SEQ: AtomicU32 = AtomicU32::new(0); static LAST_PASTE_MS: AtomicU64 = AtomicU64::new(0); +fn update_pressed_keys(pressed_keys: &mut HashSet, code: KeyCode, value: i32) { + if value == 1 { + pressed_keys.insert(code); + } else { + pressed_keys.remove(&code); + } +} + +fn debounce_gate(last_paste_ms: &AtomicU64, now_ms: u64, debounce_ms: u64) -> bool { + if debounce_ms == 0 { + last_paste_ms.store(now_ms, Ordering::Relaxed); + return true; + } + let last = last_paste_ms.load(Ordering::Relaxed); + let allowed = now_ms.saturating_sub(last) >= debounce_ms; + last_paste_ms.store(now_ms, Ordering::Relaxed); + allowed +} + impl KeyboardAggregator { pub fn new( dev: Device, @@ -89,11 +108,7 @@ impl KeyboardAggregator { } let code = KeyCode::new(ev.code()); let value = ev.value(); - if value == 1 { - self.pressed_keys.insert(code); - } else { - self.pressed_keys.remove(&code); - } + update_pressed_keys(&mut self.pressed_keys, code, value); let swallowed = self.try_handle_paste_event(code, value); if !swallowed && !self.sending_disabled { @@ -132,16 +147,7 @@ impl KeyboardAggregator { } let code = KeyCode::new(ev.code()); let value = ev.value(); - - match value { - 1 => { - self.pressed_keys.insert(code); - } // press - 0 => { - self.pressed_keys.remove(&code); - } // release - _ => {} - } + update_pressed_keys(&mut self.pressed_keys, code, value); if self.try_handle_paste_event(code, value) { continue; @@ -356,14 +362,7 @@ impl KeyboardAggregator { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; - if debounce_ms == 0 { - LAST_PASTE_MS.store(now_ms, Ordering::Relaxed); - return true; - } - let last = LAST_PASTE_MS.load(Ordering::Relaxed); - let allowed = now_ms.saturating_sub(last) >= debounce_ms; - LAST_PASTE_MS.store(now_ms, Ordering::Relaxed); - allowed + debounce_gate(&LAST_PASTE_MS, now_ms, debounce_ms) } #[cfg(coverage)] diff --git a/scripts/ci/hygiene_gate_baseline.json b/scripts/ci/hygiene_gate_baseline.json index b79ec94..41130f7 100644 --- a/scripts/ci/hygiene_gate_baseline.json +++ b/scripts/ci/hygiene_gate_baseline.json @@ -1,249 +1,274 @@ { "files": { "client/src/app.rs": { + "loc": 546, "clippy_warnings": 42, - "doc_debt": 10, - "loc": 548 + "doc_debt": 10 }, "client/src/app_support.rs": { + "loc": 128, "clippy_warnings": 0, - "doc_debt": 3, - "loc": 129 + "doc_debt": 3 }, "client/src/handshake.rs": { + "loc": 194, "clippy_warnings": 0, - "doc_debt": 3, - "loc": 194 + "doc_debt": 3 }, "client/src/input/camera.rs": { + "loc": 372, "clippy_warnings": 38, - "doc_debt": 6, - "loc": 368 + "doc_debt": 7 }, "client/src/input/inputs.rs": { + "loc": 673, "clippy_warnings": 42, - "doc_debt": 16, - "loc": 669 + "doc_debt": 16 }, "client/src/input/keyboard.rs": { + "loc": 580, "clippy_warnings": 24, - "doc_debt": 17, - "loc": 570 + "doc_debt": 18 }, "client/src/input/keymap.rs": { + "loc": 196, "clippy_warnings": 8, - "doc_debt": 0, - "loc": 196 + "doc_debt": 0 }, "client/src/input/microphone.rs": { + "loc": 166, "clippy_warnings": 17, - "doc_debt": 2, - "loc": 166 + "doc_debt": 2 }, "client/src/input/mod.rs": { + "loc": 8, "clippy_warnings": 0, - "doc_debt": 0, - "loc": 8 + "doc_debt": 0 }, "client/src/input/mouse.rs": { + "loc": 317, "clippy_warnings": 40, - "doc_debt": 8, - "loc": 317 + "doc_debt": 8 }, "client/src/launcher/clipboard.rs": { + "loc": 177, "clippy_warnings": 2, - "doc_debt": 1, - "loc": 176 + "doc_debt": 1 + }, + "client/src/launcher/device_test.rs": { + "loc": 155, + "clippy_warnings": 8, + "doc_debt": 7 }, "client/src/launcher/devices.rs": { + "loc": 158, "clippy_warnings": 6, - "doc_debt": 3, - "loc": 154 + "doc_debt": 3 }, "client/src/launcher/diagnostics.rs": { + "loc": 175, "clippy_warnings": 17, - "doc_debt": 3, - "loc": 172 + "doc_debt": 3 }, "client/src/launcher/mod.rs": { + "loc": 195, "clippy_warnings": 6, - "doc_debt": 4, - "loc": 182 + "doc_debt": 4 + }, + "client/src/launcher/power.rs": { + "loc": 61, + "clippy_warnings": 0, + "doc_debt": 0 }, "client/src/launcher/preview.rs": { + "loc": 293, "clippy_warnings": 20, - "doc_debt": 6, - "loc": 293 + "doc_debt": 6 }, "client/src/launcher/state.rs": { + "loc": 360, "clippy_warnings": 14, - "doc_debt": 13, - "loc": 306 + "doc_debt": 15 }, "client/src/launcher/ui.rs": { - "clippy_warnings": 18, - "doc_debt": 15, - "loc": 996 + "loc": 574, + "clippy_warnings": 4, + "doc_debt": 1 + }, + "client/src/launcher/ui_components.rs": { + "loc": 531, + "clippy_warnings": 8, + "doc_debt": 4 + }, + "client/src/launcher/ui_runtime.rs": { + "loc": 439, + "clippy_warnings": 10, + "doc_debt": 18 }, "client/src/layout.rs": { + "loc": 78, "clippy_warnings": 6, - "doc_debt": 0, - "loc": 78 + "doc_debt": 0 }, "client/src/lib.rs": { + "loc": 14, "clippy_warnings": 0, - "doc_debt": 0, - "loc": 14 + "doc_debt": 0 }, "client/src/main.rs": { + "loc": 96, "clippy_warnings": 2, - "doc_debt": 2, - "loc": 93 + "doc_debt": 2 }, "client/src/output/audio.rs": { - "clippy_warnings": 43, - "doc_debt": 5, - "loc": 200 + "loc": 195, + "clippy_warnings": 37, + "doc_debt": 5 }, "client/src/output/display.rs": { + "loc": 81, "clippy_warnings": 0, - "doc_debt": 0, - "loc": 81 + "doc_debt": 0 }, "client/src/output/layout.rs": { + "loc": 155, "clippy_warnings": 4, - "doc_debt": 2, - "loc": 155 + "doc_debt": 2 }, "client/src/output/mod.rs": { + "loc": 6, "clippy_warnings": 0, - "doc_debt": 0, - "loc": 6 + "doc_debt": 0 }, "client/src/output/video.rs": { + "loc": 547, "clippy_warnings": 36, - "doc_debt": 4, - "loc": 545 + "doc_debt": 4 }, "client/src/paste.rs": { + "loc": 46, "clippy_warnings": 2, - "doc_debt": 1, - "loc": 46 + "doc_debt": 1 }, "common/src/bin/cli.rs": { + "loc": 3, "clippy_warnings": 0, - "doc_debt": 0, - "loc": 3 + "doc_debt": 0 }, "common/src/cli.rs": { + "loc": 22, "clippy_warnings": 0, - "doc_debt": 0, - "loc": 22 + "doc_debt": 0 }, "common/src/hid.rs": { + "loc": 134, "clippy_warnings": 0, - "doc_debt": 2, - "loc": 134 + "doc_debt": 2 }, "common/src/lib.rs": { + "loc": 22, "clippy_warnings": 0, - "doc_debt": 0, - "loc": 22 + "doc_debt": 0 }, "common/src/paste.rs": { + "loc": 95, "clippy_warnings": 0, - "doc_debt": 2, - "loc": 95 + "doc_debt": 2 }, "server/src/audio.rs": { + "loc": 386, "clippy_warnings": 37, - "doc_debt": 7, - "loc": 386 + "doc_debt": 7 }, "server/src/bin/lesavka-uvc.real.inc": { + "loc": 0, "clippy_warnings": 31, - "doc_debt": 0, - "loc": 0 + "doc_debt": 0 }, "server/src/bin/lesavka-uvc.rs": { + "loc": 710, "clippy_warnings": 0, - "doc_debt": 17, - "loc": 700 + "doc_debt": 17 }, "server/src/camera.rs": { + "loc": 392, "clippy_warnings": 12, - "doc_debt": 11, - "loc": 392 + "doc_debt": 11 }, "server/src/camera_runtime.rs": { + "loc": 200, "clippy_warnings": 10, - "doc_debt": 5, - "loc": 198 + "doc_debt": 5 + }, + "server/src/capture_power.rs": { + "loc": 295, + "clippy_warnings": 8, + "doc_debt": 7 }, "server/src/gadget.rs": { + "loc": 327, "clippy_warnings": 30, - "doc_debt": 7, - "loc": 327 + "doc_debt": 7 }, "server/src/handshake.rs": { + "loc": 40, "clippy_warnings": 2, - "doc_debt": 1, - "loc": 40 + "doc_debt": 1 }, "server/src/lib.rs": { + "loc": 14, "clippy_warnings": 0, - "doc_debt": 0, - "loc": 13 + "doc_debt": 0 }, "server/src/main.rs": { - "clippy_warnings": 14, - "doc_debt": 15, - "loc": 508 + "loc": 565, + "clippy_warnings": 10, + "doc_debt": 12 }, "server/src/paste.rs": { + "loc": 207, "clippy_warnings": 6, - "doc_debt": 3, - "loc": 205 + "doc_debt": 3 }, "server/src/runtime_support.rs": { + "loc": 387, "clippy_warnings": 14, - "doc_debt": 8, - "loc": 387 + "doc_debt": 8 }, "server/src/uvc_control/model.rs": { + "loc": 510, "clippy_warnings": 0, - "doc_debt": 11, - "loc": 510 + "doc_debt": 11 }, "server/src/uvc_control/protocol.rs": { + "loc": 403, "clippy_warnings": 0, - "doc_debt": 11, - "loc": 403 + "doc_debt": 11 }, "server/src/uvc_runtime.rs": { + "loc": 241, "clippy_warnings": 4, - "doc_debt": 5, - "loc": 236 + "doc_debt": 5 }, "server/src/video.rs": { + "loc": 344, "clippy_warnings": 25, - "doc_debt": 2, - "loc": 339 + "doc_debt": 2 }, "server/src/video_sinks.rs": { + "loc": 559, "clippy_warnings": 78, - "doc_debt": 11, - "loc": 559 + "doc_debt": 11 }, "server/src/video_support.rs": { + "loc": 236, "clippy_warnings": 8, - "doc_debt": 6, - "loc": 236 + "doc_debt": 6 }, "testing/src/lib.rs": { + "loc": 10, "clippy_warnings": 0, - "doc_debt": 0, - "loc": 10 + "doc_debt": 0 } } } diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index 5eab78e..3a6bc65 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -1,164 +1,168 @@ { "files": { "client/src/app.rs": { - "line_percent": 95.1219512195122, - "loc": 548 + "loc": 546, + "line_percent": 95.1219512195122 }, "client/src/app_support.rs": { - "line_percent": 100.0, - "loc": 129 + "loc": 128, + "line_percent": 100.0 }, "client/src/handshake.rs": { - "line_percent": 96.15384615384616, - "loc": 194 + "loc": 194, + "line_percent": 96.15384615384616 }, "client/src/input/camera.rs": { - "line_percent": 97.31182795698925, - "loc": 368 + "loc": 372, + "line_percent": 98.42931937172776 }, "client/src/input/inputs.rs": { - "line_percent": 97.55, - "loc": 669 + "loc": 673, + "line_percent": 97.55102040816327 }, "client/src/input/keyboard.rs": { - "line_percent": 95.7, - "loc": 570 + "loc": 580, + "line_percent": 95.9409594095941 }, "client/src/input/keymap.rs": { - "line_percent": 100.0, - "loc": 196 + "loc": 196, + "line_percent": 100.0 }, "client/src/input/microphone.rs": { - "line_percent": 95.94594594594594, - "loc": 166 + "loc": 166, + "line_percent": 95.94594594594594 }, "client/src/input/mouse.rs": { - "line_percent": 97.32142857142857, - "loc": 317 + "loc": 317, + "line_percent": 97.32142857142857 }, "client/src/launcher/clipboard.rs": { - "line_percent": 97.96, - "loc": 176 + "loc": 177, + "line_percent": 98.0 }, "client/src/launcher/devices.rs": { - "line_percent": 98.09523809523807, - "loc": 154 + "loc": 158, + "line_percent": 98.13084112149532 }, "client/src/launcher/diagnostics.rs": { - "line_percent": 97.11538461538461, - "loc": 172 + "loc": 175, + "line_percent": 97.14285714285714 }, "client/src/launcher/mod.rs": { - "line_percent": 95.08, - "loc": 181 + "loc": 195, + "line_percent": 95.23809523809523 }, "client/src/launcher/state.rs": { - "line_percent": 98.0, - "loc": 306 + "loc": 360, + "line_percent": 98.29059829059828 }, "client/src/launcher/ui.rs": { - "line_percent": 100.0, - "loc": 996 + "loc": 574, + "line_percent": 100.0 }, "client/src/layout.rs": { - "line_percent": 97.72727272727273, - "loc": 78 + "loc": 78, + "line_percent": 97.72727272727273 }, "client/src/main.rs": { - "line_percent": 96.90721649484536, - "loc": 93 + "loc": 96, + "line_percent": 97.0873786407767 }, "client/src/output/audio.rs": { - "line_percent": 98.59154929577466, - "loc": 200 + "loc": 195, + "line_percent": 98.78048780487805 }, "client/src/output/display.rs": { - "line_percent": 97.61904761904762, - "loc": 81 + "loc": 81, + "line_percent": 97.61904761904762 }, "client/src/output/layout.rs": { - "line_percent": 98.9795918367347, - "loc": 155 + "loc": 155, + "line_percent": 98.9795918367347 }, "client/src/output/video.rs": { - "line_percent": 96.23, - "loc": 545 + "loc": 547, + "line_percent": 96.22641509433963 }, "client/src/paste.rs": { - "line_percent": 96.29629629629629, - "loc": 46 + "loc": 46, + "line_percent": 96.29629629629629 }, "common/src/bin/cli.rs": { - "line_percent": 100.0, - "loc": 3 + "loc": 3, + "line_percent": 100.0 }, "common/src/cli.rs": { - "line_percent": 100.0, - "loc": 22 + "loc": 22, + "line_percent": 100.0 }, "common/src/hid.rs": { - "line_percent": 100.0, - "loc": 134 + "loc": 134, + "line_percent": 100.0 }, "common/src/lib.rs": { - "line_percent": 100.0, - "loc": 22 + "loc": 22, + "line_percent": 100.0 }, "common/src/paste.rs": { - "line_percent": 100.0, - "loc": 95 + "loc": 95, + "line_percent": 100.0 }, "server/src/audio.rs": { - "line_percent": 98.9010989010989, - "loc": 386 + "loc": 386, + "line_percent": 98.9010989010989 }, "server/src/bin/lesavka-uvc.rs": { - "line_percent": 96.27906976744185, - "loc": 700 + "loc": 710, + "line_percent": 96.35535307517085 }, "server/src/camera.rs": { - "line_percent": 99.09909909909909, - "loc": 392 + "loc": 392, + "line_percent": 99.09909909909909 }, "server/src/camera_runtime.rs": { - "line_percent": 96.66666666666667, - "loc": 198 + "loc": 200, + "line_percent": 100.0 + }, + "server/src/capture_power.rs": { + "loc": 295, + "line_percent": 100.0 }, "server/src/gadget.rs": { - "line_percent": 96.875, - "loc": 327 + "loc": 327, + "line_percent": 96.875 }, "server/src/handshake.rs": { - "line_percent": 100.0, - "loc": 40 + "loc": 40, + "line_percent": 100.0 }, "server/src/main.rs": { - "line_percent": 98.4375, - "loc": 508 + "loc": 565, + "line_percent": 95.17241379310344 }, "server/src/paste.rs": { - "line_percent": 97.08, - "loc": 205 + "loc": 207, + "line_percent": 97.12230215827337 }, "server/src/runtime_support.rs": { - "line_percent": 96.42857142857143, - "loc": 387 + "loc": 387, + "line_percent": 96.42857142857143 }, "server/src/uvc_runtime.rs": { - "line_percent": 97.01492537313433, - "loc": 236 + "loc": 241, + "line_percent": 97.14285714285714 }, "server/src/video.rs": { - "line_percent": 100.0, - "loc": 339 + "loc": 344, + "line_percent": 100.0 }, "server/src/video_sinks.rs": { - "line_percent": 100.0, - "loc": 559 + "loc": 559, + "line_percent": 100.0 }, "server/src/video_support.rs": { - "line_percent": 96.03174603174604, - "loc": 236 + "loc": 236, + "line_percent": 96.03174603174604 } } } diff --git a/server/src/video.rs b/server/src/video.rs index cc91b41..b57c95b 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -58,8 +58,11 @@ pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Res return Err(anyhow::anyhow!("invalid video source")); } + let coverage_override = std::env::var("LESAVKA_TEST_VIDEO_SOURCE").ok(); let use_test_src = - dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc"); + dev.eq_ignore_ascii_case("testsrc") + || dev.eq_ignore_ascii_case("videotestsrc") + || coverage_override.as_deref() == Some(dev); if !use_test_src { return Err(anyhow::anyhow!("video source unavailable")); } diff --git a/testing/tests/client_camera_include_contract.rs b/testing/tests/client_camera_include_contract.rs index c95f1b7..5821d39 100644 --- a/testing/tests/client_camera_include_contract.rs +++ b/testing/tests/client_camera_include_contract.rs @@ -89,6 +89,27 @@ mod camera_include_contract { ); } + #[test] + #[cfg(coverage)] + #[serial] + fn find_device_returns_dev_path_when_fake_target_matches_capture_shape() { + let dir = tempdir().expect("tempdir"); + let by_id = dir.path().join("by-id"); + std::fs::create_dir_all(&by_id).expect("create by-id"); + symlink("../video42", by_id.join("usb-Cam_42")).expect("create camera symlink"); + + with_var( + "LESAVKA_CAM_BY_ID_DIR", + Some(by_id.to_string_lossy().to_string()), + || { + with_var("LESAVKA_CAM_DEV_ROOT", Some("/dev".to_string()), || { + let found = CameraCapture::find_device("Cam_42"); + assert_eq!(found.as_deref(), Some("/dev/video42")); + }); + }, + ); + } + #[test] #[serial] fn new_covers_test_pattern_and_mjpg_source_branches() { @@ -160,6 +181,24 @@ mod camera_include_contract { }); } + #[test] + #[cfg(coverage)] + #[serial] + fn new_covers_non_x264_encoder_option_branch_in_coverage_harness() { + init_gst(); + let cfg = CameraConfig { + codec: CameraCodec::H264, + width: 640, + height: 480, + fps: 30, + }; + + with_var("LESAVKA_CAM_TEST_ENCODER", Some("v4l2h264enc"), || { + let result = CameraCapture::new(Some("test"), Some(cfg)); + assert!(result.is_ok() || result.is_err()); + }); + } + #[test] #[serial] fn pull_returns_packet_from_test_pattern_pipeline_when_available() { diff --git a/testing/tests/client_keyboard_include_extra_contract.rs b/testing/tests/client_keyboard_include_extra_contract.rs index 8ce9f88..87b0a31 100644 --- a/testing/tests/client_keyboard_include_extra_contract.rs +++ b/testing/tests/client_keyboard_include_extra_contract.rs @@ -142,6 +142,55 @@ mod keyboard_contract_extra { assert_eq!(snapshot.len(), 2); } + #[test] + fn update_pressed_keys_tracks_press_and_release_values() { + let mut pressed = HashSet::new(); + update_pressed_keys(&mut pressed, evdev::KeyCode::KEY_A, 1); + assert!(pressed.contains(&evdev::KeyCode::KEY_A)); + + update_pressed_keys(&mut pressed, evdev::KeyCode::KEY_A, 0); + assert!(!pressed.contains(&evdev::KeyCode::KEY_A)); + + update_pressed_keys(&mut pressed, evdev::KeyCode::KEY_B, 2); + assert!(!pressed.contains(&evdev::KeyCode::KEY_B)); + } + + #[test] + fn debounce_gate_honors_zero_and_nonzero_windows() { + let last = AtomicU64::new(123); + assert!(debounce_gate(&last, 500, 0)); + assert_eq!(last.load(Ordering::Relaxed), 500); + + last.store(9_900, Ordering::Relaxed); + assert!(!debounce_gate(&last, 10_000, 500)); + assert_eq!(last.load(Ordering::Relaxed), 10_000); + + assert!(debounce_gate(&last, 10_700, 500)); + assert_eq!(last.load(Ordering::Relaxed), 10_700); + } + + #[test] + #[serial] + fn paste_rpc_enabled_from_env_honors_flag_and_key_variants() { + with_var("LESAVKA_PASTE_RPC", Some("0"), || { + with_var("LESAVKA_PASTE_KEY", Some("shared-key"), || { + assert!(!paste_rpc_enabled_from_env()); + }); + }); + + with_var("LESAVKA_PASTE_RPC", Some("1"), || { + with_var("LESAVKA_PASTE_KEY", Some(" "), || { + assert!(!paste_rpc_enabled_from_env()); + }); + }); + + with_var("LESAVKA_PASTE_RPC", Some("1"), || { + with_var("LESAVKA_PASTE_KEY", Some("shared-key"), || { + assert!(paste_rpc_enabled_from_env()); + }); + }); + } + #[test] #[serial] fn set_send_false_blocks_manual_empty_report() { @@ -197,6 +246,38 @@ mod keyboard_contract_extra { }); } + #[test] + #[serial] + fn read_clipboard_text_returns_none_when_command_is_empty_and_fallback_fails() { + with_var("LESAVKA_CLIPBOARD_CMD", Some("printf ''"), || { + with_fake_path_command("wl-paste", "#!/usr/bin/env sh\nexit 1\n", || { + assert!(read_clipboard_text().is_none()); + }); + }); + } + + #[test] + #[cfg(coverage)] + #[serial] + fn read_clipboard_text_prefers_nonempty_command_output_in_coverage() { + with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'coverage-clipboard'"), || { + let text = read_clipboard_text().expect("coverage clipboard text"); + assert_eq!(text, "coverage-clipboard"); + }); + } + + #[test] + #[cfg(coverage)] + #[serial] + fn read_clipboard_text_tolerates_missing_shell_in_coverage() { + let dir = tempdir().expect("tempdir"); + with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'coverage-clipboard'"), || { + with_var("PATH", Some(dir.path().to_string_lossy().to_string()), || { + assert!(read_clipboard_text().is_none()); + }); + }); + } + #[test] #[serial] fn paste_via_rpc_returns_true_for_empty_clipboard_payload() { @@ -223,6 +304,28 @@ mod keyboard_contract_extra { }); } + #[test] + #[cfg(coverage)] + #[serial] + fn paste_clipboard_skips_unsupported_chars_in_coverage() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-coverage-unsupported")) + else { + return; + }; + let (mut agg, mut rx) = new_aggregator(dev); + agg.paste_enabled = true; + + with_var("LESAVKA_CLIPBOARD_CMD", Some("printf '🚀'"), || { + agg.paste_clipboard(); + }); + + assert!( + rx.try_recv().is_err(), + "unsupported clipboard characters should not emit HID reports" + ); + } + #[test] #[serial] fn set_grab_path_is_non_panicking() { @@ -253,4 +356,112 @@ mod keyboard_contract_extra { let pkt = rx.try_recv().expect("swallow report"); assert_eq!(pkt.data, vec![0; 8]); } + + #[test] + #[cfg(coverage)] + #[serial] + fn try_handle_paste_event_coverage_path_runs_hid_paste_and_empty_report() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-coverage-paste")) + else { + return; + }; + let (tx, mut rx) = tokio::sync::broadcast::channel(32); + let mut agg = KeyboardAggregator::new(dev, false, tx, None); + agg.paste_enabled = true; + agg.paste_rpc_enabled = false; + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT); + agg.pressed_keys.insert(evdev::KeyCode::KEY_V); + + with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'a'"), || { + with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || { + with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0"), || { + assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1)); + }); + }); + }); + + let mut saw_hid_payload = false; + let mut saw_empty = false; + while let Ok(pkt) = rx.try_recv() { + if pkt.data == vec![0; 8] { + saw_empty = true; + } + if pkt.data.len() == 8 && pkt.data.iter().any(|byte| *byte != 0) { + saw_hid_payload = true; + } + } + assert!(saw_hid_payload, "coverage paste path should emit HID reports"); + assert!(saw_empty, "coverage paste path should end with an empty report"); + } + + #[test] + #[cfg(coverage)] + #[serial] + fn try_handle_paste_event_coverage_path_respects_debounce_fallthrough() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-coverage-debounce")) + else { + return; + }; + let (tx, mut rx) = tokio::sync::broadcast::channel(32); + let mut agg = KeyboardAggregator::new(dev, false, tx, None); + agg.paste_enabled = true; + agg.paste_rpc_enabled = false; + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT); + agg.pressed_keys.insert(evdev::KeyCode::KEY_V); + + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + LAST_PASTE_MS.store(now_ms, Ordering::Relaxed); + + with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'a'"), || { + with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || { + with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("999999"), || { + assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1)); + }); + }); + }); + + let pkt = rx + .try_recv() + .expect("debounced paste should still emit a swallowed empty report"); + assert_eq!(pkt.data, vec![0; 8]); + assert!(rx.try_recv().is_err(), "debounced paste should not emit HID reports"); + LAST_PASTE_MS.store(0, Ordering::Relaxed); + } + + #[test] + #[cfg(coverage)] + #[serial] + fn try_handle_paste_event_coverage_path_invokes_rpc_when_enabled() { + let Some(dev) = + open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-coverage-rpc")) + else { + return; + }; + let (paste_tx, mut paste_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (tx, _rx) = tokio::sync::broadcast::channel(32); + let mut agg = KeyboardAggregator::new(dev, false, tx, Some(paste_tx)); + agg.paste_enabled = true; + agg.paste_rpc_enabled = true; + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT); + agg.pressed_keys.insert(evdev::KeyCode::KEY_V); + + with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'rpc-coverage'"), || { + with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || { + with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0"), || { + assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1)); + }); + }); + }); + + let payload = paste_rx.try_recv().expect("rpc payload"); + assert_eq!(payload, "rpc-coverage"); + } } diff --git a/testing/tests/server_camera_runtime_contract.rs b/testing/tests/server_camera_runtime_contract.rs index 3f921e1..9fb9682 100644 --- a/testing/tests/server_camera_runtime_contract.rs +++ b/testing/tests/server_camera_runtime_contract.rs @@ -66,6 +66,33 @@ fn activate_tracks_latest_generation_across_repeated_failures() { }); } +#[test] +#[cfg(coverage)] +fn activate_non_uvc_returns_internal_error_in_coverage_harness() { + let runtime = CameraRuntime::new(); + let cfg = CameraConfig { + output: CameraOutput::Hdmi, + codec: CameraCodec::H264, + width: 1920, + height: 1080, + fps: 30, + hdmi: Some(HdmiConnector { + name: String::from("HDMI-A-1"), + id: Some(1), + }), + }; + + let rt = Runtime::new().expect("runtime"); + let result = rt.block_on(runtime.activate(&cfg)); + match result { + Ok(_) => panic!("coverage harness should not create a real relay"), + Err(err) => assert_eq!(err.code(), Code::Internal), + } + + assert!(runtime.is_active(1)); + assert!(!runtime.is_active(2)); +} + #[test] fn camera_cfg_eq_handles_none_and_hdmi_connector_edges() { let uvc_a = CameraConfig { diff --git a/testing/tests/server_main_binary_extra_contract.rs b/testing/tests/server_main_binary_extra_contract.rs index 87fcde7..4f2c393 100644 --- a/testing/tests/server_main_binary_extra_contract.rs +++ b/testing/tests/server_main_binary_extra_contract.rs @@ -10,7 +10,9 @@ mod server_main_binary_extra { include!(env!("LESAVKA_SERVER_MAIN_SRC")); + use futures_util::stream; use lesavka_common::lesavka::relay_client::RelayClient; + use std::path::Path; use serial_test::serial; use temp_env::with_var; use tempfile::tempdir; @@ -28,6 +30,32 @@ mod server_main_binary_extra { panic!("failed to connect to local tonic server"); } + fn write_file(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + std::fs::write(path, content).expect("write file"); + } + + fn with_fake_gadget_roots(sys_root: &Path, cfg_root: &Path, f: impl FnOnce()) { + let sys_root = sys_root.to_string_lossy().to_string(); + let cfg_root = cfg_root.to_string_lossy().to_string(); + with_var("LESAVKA_GADGET_SYSFS_ROOT", Some(sys_root), || { + with_var("LESAVKA_GADGET_CONFIGFS_ROOT", Some(cfg_root), f); + }); + } + + fn build_fake_gadget_tree(base: &Path, ctrl: &str, gadget_name: &str, state: &str) { + write_file( + &base.join(format!("sys/class/udc/{ctrl}/state")), + &format!("{state}\n"), + ); + write_file( + &base.join(format!("cfg/{gadget_name}/UDC")), + &format!("{ctrl}\n"), + ); + } + fn build_handler_for_tests_with_modes( kb_writable: bool, ms_writable: bool, @@ -392,4 +420,79 @@ mod server_main_binary_extra { server.abort(); }); } + + #[test] + #[serial] + fn reset_usb_succeeds_with_fake_cycle_and_override_hid_paths() { + let dir = tempdir().expect("tempdir"); + let hid_dir = dir.path().join("hid"); + std::fs::create_dir_all(&hid_dir).expect("create hid dir"); + std::fs::write(hid_dir.join("hidg0"), "").expect("create hidg0"); + std::fs::write(hid_dir.join("hidg1"), "").expect("create hidg1"); + build_fake_gadget_tree(dir.path(), "fake-ctrl.usb", "lesavka", "configured"); + + with_fake_gadget_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { + with_var( + "LESAVKA_HID_DIR", + Some(hid_dir.to_string_lossy().to_string()), + || { + let kb = tokio::fs::File::from_std( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(hid_dir.join("hidg0")) + .expect("open hidg0"), + ); + let ms = tokio::fs::File::from_std( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(hid_dir.join("hidg1")) + .expect("open hidg1"), + ); + let handler = Handler { + kb: std::sync::Arc::new(tokio::sync::Mutex::new(kb)), + ms: std::sync::Arc::new(tokio::sync::Mutex::new(ms)), + gadget: UsbGadget::new("lesavka"), + did_cycle: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + camera_rt: std::sync::Arc::new(CameraRuntime::new()), + capture_power: CapturePowerManager::new(), + }; + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let reply = rt + .block_on(async { handler.reset_usb(tonic::Request::new(Empty {})).await }) + .expect("reset usb should succeed on fake gadget tree") + .into_inner(); + assert!(reply.ok); + }, + ); + }); + } + + #[test] + fn guarded_video_stream_forwards_inner_packets() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let lease = CapturePowerManager::new().acquire().await; + let packet = VideoPacket { + id: 2, + pts: 42, + data: vec![9, 8, 7], + }; + let mut guarded = GuardedVideoStream { + inner: stream::iter(vec![Ok(packet.clone())]), + _lease: lease, + }; + + let observed = guarded + .next() + .await + .expect("guarded stream item") + .expect("packet"); + assert_eq!(observed.id, packet.id); + assert_eq!(observed.pts, packet.pts); + assert_eq!(observed.data, packet.data); + assert!(guarded.next().await.is_none()); + }); + } } diff --git a/testing/tests/server_main_rpc_contract.rs b/testing/tests/server_main_rpc_contract.rs index 2e65499..966a02d 100644 --- a/testing/tests/server_main_rpc_contract.rs +++ b/testing/tests/server_main_rpc_contract.rs @@ -13,7 +13,10 @@ mod server_main_rpc { use temp_env::with_var; use tempfile::tempdir; - fn build_handler_for_tests() -> (tempfile::TempDir, Handler) { + fn build_handler_for_tests_with_modes( + kb_writable: bool, + ms_writable: bool, + ) -> (tempfile::TempDir, Handler) { let dir = tempdir().expect("tempdir"); let kb_path = dir.path().join("hidg0.bin"); let ms_path = dir.path().join("hidg1.bin"); @@ -23,14 +26,18 @@ mod server_main_rpc { let kb = tokio::fs::File::from_std( std::fs::OpenOptions::new() .read(true) - .write(true) + .write(kb_writable) + .create(kb_writable) + .truncate(kb_writable) .open(&kb_path) .expect("open kb"), ); let ms = tokio::fs::File::from_std( std::fs::OpenOptions::new() .read(true) - .write(true) + .write(ms_writable) + .create(ms_writable) + .truncate(ms_writable) .open(&ms_path) .expect("open ms"), ); @@ -48,6 +55,10 @@ mod server_main_rpc { ) } + fn build_handler_for_tests() -> (tempfile::TempDir, Handler) { + build_handler_for_tests_with_modes(true, true) + } + #[test] #[serial] fn reopen_hid_returns_error_without_hid_endpoints() { @@ -77,6 +88,53 @@ mod server_main_rpc { assert_eq!(err.code(), tonic::Code::Internal); } + #[test] + #[serial] + fn capture_video_right_eye_surfaces_internal_error_without_device() { + let (_dir, handler) = build_handler_for_tests(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let result = rt.block_on(async { + handler + .capture_video(tonic::Request::new(MonitorRequest { + id: 1, + max_bitrate: 3_000, + })) + .await + }); + let err = match result { + Ok(_) => panic!("missing right-eye camera device should fail"), + Err(err) => err, + }; + assert_eq!(err.code(), tonic::Code::Internal); + } + + #[test] + #[cfg(coverage)] + #[serial] + fn capture_video_returns_guarded_stream_when_coverage_source_is_overridden() { + let (_dir, handler) = build_handler_for_tests(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + with_var("LESAVKA_TEST_VIDEO_SOURCE", Some("/dev/lesavka_l_eye"), || { + let mut stream = rt + .block_on(async { + handler + .capture_video(tonic::Request::new(MonitorRequest { + id: 0, + max_bitrate: 3_000, + })) + .await + }) + .expect("coverage video stream should succeed") + .into_inner(); + let packet = rt + .block_on(async { stream.next().await }) + .expect("stream item") + .expect("packet"); + assert_eq!(packet.id, 0); + assert!(!packet.data.is_empty()); + }); + } + #[test] #[serial] fn paste_text_accepts_encrypted_payload_and_returns_reply() { @@ -102,6 +160,29 @@ mod server_main_rpc { ); } + #[test] + #[serial] + fn paste_text_returns_structured_error_when_hid_write_fails() { + let (_dir, handler) = build_handler_for_tests_with_modes(false, true); + with_var( + "LESAVKA_PASTE_KEY", + Some("hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"), + || { + with_var("LESAVKA_PASTE_DELAY_MS", Some("0"), || { + let req = + lesavka_client::paste::build_paste_request("hello").expect("build request"); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let reply = rt + .block_on(async { handler.paste_text(tonic::Request::new(req)).await }) + .expect("paste rpc should return structured reply") + .into_inner(); + assert!(!reply.ok); + assert!(!reply.error.is_empty()); + }); + }, + ); + } + #[test] #[serial] fn capture_audio_accepts_secondary_monitor_id_and_fails_internally_without_sink() { @@ -119,4 +200,119 @@ mod server_main_rpc { }; assert_eq!(err.code(), tonic::Code::Internal); } + + #[test] + #[cfg(coverage)] + #[serial] + fn capture_power_rpcs_surface_stub_snapshot_and_manual_modes() { + let (_dir, handler) = build_handler_for_tests(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + + let snapshot = rt + .block_on(async { handler.get_capture_power(tonic::Request::new(Empty {})).await }) + .expect("capture power snapshot") + .into_inner(); + assert!(snapshot.available); + assert!(!snapshot.enabled); + assert_eq!(snapshot.mode, "auto"); + + let forced_on = rt + .block_on(async { + handler + .set_capture_power(tonic::Request::new(SetCapturePowerRequest { + enabled: true, + })) + .await + }) + .expect("force capture power on") + .into_inner(); + assert!(forced_on.available); + assert!(forced_on.enabled); + assert_eq!(forced_on.mode, "forced-on"); + + let forced_off = rt + .block_on(async { + handler + .set_capture_power(tonic::Request::new(SetCapturePowerRequest { + enabled: false, + })) + .await + }) + .expect("force capture power off") + .into_inner(); + assert!(forced_off.available); + assert!(!forced_off.enabled); + assert_eq!(forced_off.mode, "forced-off"); + } + + #[test] + #[cfg(coverage)] + #[serial] + fn reset_usb_returns_internal_error_when_reopen_fails_after_successful_cycle() { + let dir = tempdir().expect("tempdir"); + std::fs::write(dir.path().join("hidg0.bin"), "").expect("create kb file"); + std::fs::write(dir.path().join("hidg1.bin"), "").expect("create ms file"); + std::fs::create_dir_all(dir.path().join("sys/class/udc/fake-ctrl.usb")) + .expect("create udc dir"); + std::fs::create_dir_all(dir.path().join("cfg/lesavka")).expect("create cfg dir"); + std::fs::write( + dir.path().join("sys/class/udc/fake-ctrl.usb/state"), + "configured\n", + ) + .expect("write state"); + std::fs::write(dir.path().join("cfg/lesavka/UDC"), "fake-ctrl.usb\n") + .expect("write udc"); + + let kb = tokio::fs::File::from_std( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(dir.path().join("hidg0.bin")) + .expect("open kb"), + ); + let ms = tokio::fs::File::from_std( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(dir.path().join("hidg1.bin")) + .expect("open ms"), + ); + + with_var( + "LESAVKA_GADGET_SYSFS_ROOT", + Some(dir.path().join("sys").to_string_lossy().to_string()), + || { + with_var( + "LESAVKA_GADGET_CONFIGFS_ROOT", + Some(dir.path().join("cfg").to_string_lossy().to_string()), + || { + let handler = Handler { + kb: std::sync::Arc::new(tokio::sync::Mutex::new(kb)), + ms: std::sync::Arc::new(tokio::sync::Mutex::new(ms)), + gadget: UsbGadget::new("lesavka"), + did_cycle: std::sync::Arc::new( + std::sync::atomic::AtomicBool::new(false), + ), + camera_rt: std::sync::Arc::new(CameraRuntime::new()), + capture_power: CapturePowerManager::new(), + }; + + with_var( + "LESAVKA_HID_DIR", + Some(dir.path().join("missing").to_string_lossy().to_string()), + || { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let err = rt + .block_on(async { + handler.reset_usb(tonic::Request::new(Empty {})).await + }) + .expect_err("reopen hid should fail after successful cycle"); + assert_eq!(err.code(), tonic::Code::Internal); + }, + ); + }, + ); + }, + ); + } }