//! Extra include-based coverage for input aggregator edge cases. //! //! Scope: keep additional quick-toggle regression checks in a separate file so //! each testing module stays under the 500 LOC contract. //! Targets: `client/src/input/inputs.rs`. //! Why: quick swap-key taps can otherwise disappear inside one poll cycle and //! make local/remote handoff feel flaky in the live launcher path. mod layout { pub use lesavka_client::layout::*; } mod keyboard { pub use lesavka_client::input::keyboard::*; } mod mouse { pub use lesavka_client::input::mouse::*; } #[allow(warnings)] mod inputs_contract_extra { include!(env!("LESAVKA_CLIENT_INPUTS_SRC")); use evdev::AttributeSet; use evdev::uinput::VirtualDevice; use serial_test::serial; use std::thread; fn open_virtual_device(vdev: &mut VirtualDevice) -> Option { for _ in 0..40 { if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() { if let Some(Ok(path)) = nodes.next() { if let Ok(dev) = evdev::Device::open(path) { let _ = dev.set_nonblocking(true); return Some(dev); } } } thread::sleep(std::time::Duration::from_millis(10)); } None } fn build_keyboard_pair_with_keys( name: &str, keycodes: &[evdev::KeyCode], ) -> Option<(VirtualDevice, evdev::Device)> { let mut keys = AttributeSet::::new(); for keycode in keycodes { keys.insert(*keycode); } let mut vdev = VirtualDevice::builder() .ok()? .name(name) .with_keys(&keys) .ok()? .build() .ok()?; let dev = open_virtual_device(&mut vdev)?; Some((vdev, dev)) } fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> { build_keyboard_pair_with_keys(name, &[evdev::KeyCode::KEY_A, evdev::KeyCode::KEY_ENTER]) } fn build_mouse_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> { let mut keys = AttributeSet::::new(); keys.insert(evdev::KeyCode::BTN_LEFT); let mut rel = AttributeSet::::new(); rel.insert(evdev::RelativeAxisCode::REL_X); rel.insert(evdev::RelativeAxisCode::REL_Y); let mut vdev = VirtualDevice::builder() .ok()? .name(name) .with_keys(&keys) .ok()? .with_relative_axes(&rel) .ok()? .build() .ok()?; let dev = open_virtual_device(&mut vdev)?; Some((vdev, dev)) } fn new_aggregator_with_capture(capture_remote_boot: bool) -> InputAggregator { let (kbd_tx, _) = tokio::sync::broadcast::channel(16); let (mou_tx, _) = tokio::sync::broadcast::channel(16); InputAggregator::new_with_capture_mode(false, kbd_tx, mou_tx, None, capture_remote_boot) } #[test] #[cfg(coverage)] #[serial] fn init_honors_device_selection_mismatches() { let Some((_kbd_vdev, _kbd_dev)) = build_keyboard_pair("lesavka-input-init-skip-kbd") else { return; }; let Some((_mouse_vdev, _mouse_dev)) = build_mouse_pair("lesavka-input-init-skip-mouse") else { return; }; temp_env::with_vars( [ ("LESAVKA_KEYBOARD_DEVICE", Some("/dev/input/does-not-match")), ("LESAVKA_MOUSE_DEVICE", Some("/dev/input/does-not-match")), ], || { let mut agg = new_aggregator_with_capture(true); agg.init().expect("init should tolerate skipped devices"); }, ); } #[test] #[cfg(coverage)] #[serial] fn init_stages_devices_ungrabbed_when_session_starts_local() { let Some((_kbd_vdev, _kbd_dev)) = build_keyboard_pair("lesavka-input-init-local-kbd") else { return; }; let Some((_mouse_vdev, _mouse_dev)) = build_mouse_pair("lesavka-input-init-local-mouse") else { return; }; temp_env::with_vars( [ ("LESAVKA_KEYBOARD_DEVICE", None::<&str>), ("LESAVKA_MOUSE_DEVICE", None::<&str>), ], || { let mut agg = new_aggregator_with_capture(false); agg.init().expect("init should stage local devices"); }, ); } #[test] #[serial] fn quick_toggle_detects_tap_when_press_and_release_land_in_same_poll_cycle() { let Some((mut vdev, dev)) = build_keyboard_pair("lesavka-input-quick-toggle-tap") else { return; }; let (kbd_tx, _) = tokio::sync::broadcast::channel(16); let (agg_kbd_tx, _) = tokio::sync::broadcast::channel(16); let (agg_mou_tx, _) = tokio::sync::broadcast::channel(16); let mut keyboard = KeyboardAggregator::new(dev, false, kbd_tx, None); vdev.emit(&[ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1), evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 0), ]) .expect("emit quick-toggle tap"); thread::sleep(std::time::Duration::from_millis(20)); keyboard.process_events(); let mut agg = InputAggregator::new(false, agg_kbd_tx, agg_mou_tx, None); agg.quick_toggle_key = Some(evdev::KeyCode::KEY_A); agg.keyboards.push(keyboard); assert!( agg.quick_toggle_active(), "quick-toggle should fire even when a tap starts and ends inside one poll batch" ); assert!( !agg.quick_toggle_active(), "tap activation should be consumed after one observation" ); } #[tokio::test(flavor = "current_thread")] #[cfg(coverage)] async fn run_keeps_pending_release_armed_until_tracked_key_is_released() { let Some((mut vdev, dev)) = build_keyboard_pair_with_keys("lesavka-run-held-key", &[evdev::KeyCode::KEY_A]) else { return; }; let (kbd_tx, _) = tokio::sync::broadcast::channel(16); let (mou_tx, _) = tokio::sync::broadcast::channel(16); let mut keyboard = KeyboardAggregator::new(dev, false, kbd_tx.clone(), None); vdev.emit(&[evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1, )]) .expect("emit held key"); thread::sleep(std::time::Duration::from_millis(25)); keyboard.process_events(); let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None); agg.pending_release = true; agg.pending_keys.insert(evdev::KeyCode::KEY_A); agg.keyboards.push(keyboard); let result = tokio::time::timeout(std::time::Duration::from_millis(40), agg.run()).await; assert!(result.is_err(), "held key should keep the run loop active"); assert!(agg.pending_release); } #[tokio::test(flavor = "current_thread")] #[cfg(coverage)] async fn run_finishes_pending_release_after_tracked_key_disappears() { let (kbd_tx, _) = tokio::sync::broadcast::channel(16); let (mou_tx, _) = tokio::sync::broadcast::channel(16); let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None); agg.pending_release = true; agg.pending_keys.insert(evdev::KeyCode::KEY_A); let result = tokio::time::timeout(std::time::Duration::from_millis(40), agg.run()).await; assert!( result.is_err(), "run loop should continue after releasing locally" ); assert!(agg.released); assert!(!agg.pending_release); } #[test] #[serial] fn process_keyboard_updates_merges_modifier_and_key_across_keyboards() { let Some((mut shift_vdev, shift_dev)) = build_keyboard_pair_with_keys( "lesavka-input-merge-shift", &[evdev::KeyCode::KEY_LEFTSHIFT], ) else { return; }; let Some((mut a_vdev, a_dev)) = build_keyboard_pair_with_keys("lesavka-input-merge-a", &[evdev::KeyCode::KEY_A]) else { return; }; let (kbd_tx, mut rx) = tokio::sync::broadcast::channel(16); let (mou_tx, _) = tokio::sync::broadcast::channel(16); let shift_keyboard = KeyboardAggregator::new(shift_dev, false, kbd_tx.clone(), None); let a_keyboard = KeyboardAggregator::new(a_dev, false, kbd_tx.clone(), None); let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None); agg.keyboards.push(shift_keyboard); agg.keyboards.push(a_keyboard); shift_vdev .emit(&[evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_LEFTSHIFT.0, 1, )]) .expect("emit shift"); a_vdev .emit(&[evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1, )]) .expect("emit a"); thread::sleep(std::time::Duration::from_millis(25)); temp_env::with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("0"), || { agg.process_keyboard_updates(); }); let reports: Vec> = std::iter::from_fn(|| rx.try_recv().ok().map(|pkt| pkt.data)).collect(); assert!( reports.contains(&vec![0x02, 0, 0x04, 0, 0, 0, 0, 0]), "expected merged shift+A report, got {reports:?}" ); } #[test] #[serial] fn process_keyboard_updates_keeps_quick_shift_chord_when_full_tap_lands_in_one_poll_cycle() { let Some((mut vdev, dev)) = build_keyboard_pair_with_keys( "lesavka-input-shift-batch", &[evdev::KeyCode::KEY_LEFTSHIFT, evdev::KeyCode::KEY_A], ) else { return; }; let (kbd_tx, mut rx) = tokio::sync::broadcast::channel(32); let (mou_tx, _) = tokio::sync::broadcast::channel(16); let keyboard = KeyboardAggregator::new(dev, false, kbd_tx.clone(), None); let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None); agg.keyboards.push(keyboard); vdev.emit(&[ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_LEFTSHIFT.0, 1), evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1), evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 0), evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_LEFTSHIFT.0, 0), ]) .expect("emit quick shifted tap"); thread::sleep(std::time::Duration::from_millis(25)); temp_env::with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("0"), || { agg.process_keyboard_updates(); }); let reports: Vec> = std::iter::from_fn(|| rx.try_recv().ok().map(|pkt| pkt.data)).collect(); assert!( reports.contains(&vec![0x02, 0, 0x04, 0, 0, 0, 0, 0]), "expected shifted A report from one-batch chord, got {reports:?}" ); assert!( reports.contains(&vec![0; 8]), "expected final empty report after one-batch chord, got {reports:?}" ); } #[test] #[serial] fn process_keyboard_updates_keeps_overlapping_plain_keys_from_sticking_across_keyboards() { let Some((mut a_vdev, a_dev)) = build_keyboard_pair_with_keys("lesavka-input-merge-a-only", &[evdev::KeyCode::KEY_A]) else { return; }; let Some((mut s_vdev, s_dev)) = build_keyboard_pair_with_keys("lesavka-input-merge-s-only", &[evdev::KeyCode::KEY_S]) else { return; }; let (kbd_tx, mut rx) = tokio::sync::broadcast::channel(32); let (mou_tx, _) = tokio::sync::broadcast::channel(16); let a_keyboard = KeyboardAggregator::new(a_dev, false, kbd_tx.clone(), None); let s_keyboard = KeyboardAggregator::new(s_dev, false, kbd_tx.clone(), None); let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None); agg.keyboards.push(a_keyboard); agg.keyboards.push(s_keyboard); a_vdev .emit(&[evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1, )]) .expect("emit a down"); s_vdev .emit(&[evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_S.0, 1, )]) .expect("emit s down"); thread::sleep(std::time::Duration::from_millis(25)); agg.process_keyboard_updates(); a_vdev .emit(&[evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 0, )]) .expect("emit a up"); s_vdev .emit(&[evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_S.0, 0, )]) .expect("emit s up"); thread::sleep(std::time::Duration::from_millis(25)); agg.process_keyboard_updates(); let reports: Vec> = std::iter::from_fn(|| rx.try_recv().ok().map(|pkt| pkt.data)).collect(); assert!( reports.contains(&vec![0, 0, 0x04, 0, 0, 0, 0, 0]), "expected A-down report, got {reports:?}" ); assert!( reports.iter().any(|pkt| { let keys = &pkt[2..8]; keys.contains(&0x04) && keys.contains(&0x16) }), "expected merged A+S overlap report, got {reports:?}" ); assert!( reports.contains(&vec![0, 0, 0x16, 0, 0, 0, 0, 0]), "expected lone S report after A release, got {reports:?}" ); assert!( reports.contains(&vec![0; 8]), "expected final empty report after both releases, got {reports:?}" ); } }