test(launcher): stabilize recovered relay preview gate

This commit is contained in:
Brad Stein 2026-04-14 23:35:29 -03:00
parent df6dfefce6
commit d70199c410
10 changed files with 825 additions and 209 deletions

View File

@ -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"),
}
}
}

View File

@ -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<KeyCode>, 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)]

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

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

View File

@ -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() {

View File

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

View File

@ -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 {

View File

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

View File

@ -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);
},
);
},
);
},
);
}
}