diff --git a/testing/tests/client_keyboard_include_contract.rs b/testing/tests/client_keyboard_include_contract.rs index 0300453..1616e85 100644 --- a/testing/tests/client_keyboard_include_contract.rs +++ b/testing/tests/client_keyboard_include_contract.rs @@ -269,4 +269,113 @@ mod keyboard_contract { let pkt = rx.try_recv().expect("empty report after reset"); assert_eq!(pkt.data, vec![0; 8]); } + + #[test] + #[serial] + fn set_send_false_blocks_manual_empty_report() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-nosend").map(|(_, dev)| dev)) + else { + return; + }; + let (mut agg, mut rx) = new_aggregator(dev); + agg.set_send(false); + agg.send_empty_report(); + assert!(rx.try_recv().is_err()); + } + + #[test] + #[serial] + fn process_events_respects_send_toggle() { + let Some((mut vdev, dev)) = build_keyboard("lesavka-include-kbd-send-toggle") else { + return; + }; + let (mut agg, mut rx) = new_aggregator(dev); + agg.set_send(false); + vdev.emit(&[evdev::InputEvent::new( + evdev::EventType::KEY.0, + evdev::KeyCode::KEY_B.0, + 1, + )]) + .expect("emit key"); + thread::sleep(std::time::Duration::from_millis(20)); + agg.process_events(); + assert!(rx.try_recv().is_err(), "send-disabled aggregator should not publish reports"); + } + + #[test] + #[serial] + fn paste_chord_active_supports_ctrl_v_variant() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-ctrl-v").map(|(_, dev)| dev)) + else { + return; + }; + let (mut agg, _) = new_aggregator(dev); + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); + agg.pressed_keys.insert(evdev::KeyCode::KEY_V); + with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+v"), || { + assert!(agg.paste_chord_active()); + }); + } + + #[test] + #[serial] + fn paste_debounced_rejects_rapid_repeat_with_positive_window() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-debounce").map(|(_, dev)| dev)) + else { + return; + }; + let (agg, _) = new_aggregator(dev); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_millis() as u64; + LAST_PASTE_MS.store(now_ms, Ordering::Relaxed); + with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("9999"), || { + assert!(!agg.paste_debounced()); + }); + } + + #[test] + #[serial] + fn paste_via_rpc_returns_false_without_sender() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-rpc-none").map(|(_, dev)| dev)) + else { + return; + }; + let (tx, _rx) = tokio::sync::broadcast::channel(8); + let agg = KeyboardAggregator::new(dev, false, tx, None); + assert!(!agg.paste_via_rpc()); + } + + #[test] + #[serial] + fn read_clipboard_text_returns_none_when_custom_and_fallback_tools_fail() { + with_var("LESAVKA_CLIPBOARD_CMD", Some("nonexistent-clipboard-command"), || { + with_var("PATH", Some("/tmp/definitely-missing-path"), || { + assert!(read_clipboard_text().is_none()); + }); + }); + } + + #[test] + #[serial] + fn try_handle_paste_event_swallows_incomplete_chord_sequences() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-incomplete").map(|(_, dev)| dev)) + else { + return; + }; + let (mut agg, mut rx) = new_aggregator(dev); + agg.paste_enabled = true; + agg.paste_chord_armed = true; + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); + + assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_LEFTCTRL, 1)); + let pkt = rx.try_recv().expect("swallow report"); + assert_eq!(pkt.data, vec![0; 8]); + } } diff --git a/testing/tests/client_mouse_include_contract.rs b/testing/tests/client_mouse_include_contract.rs index 821d57c..58e3d71 100644 --- a/testing/tests/client_mouse_include_contract.rs +++ b/testing/tests/client_mouse_include_contract.rs @@ -122,6 +122,27 @@ mod mouse_contract { Some((vdev, dev)) } + fn build_absolute_mouse_without_touch( + name: &str, + ) -> Option<(evdev::uinput::VirtualDevice, evdev::Device)> { + let mut keys = evdev::AttributeSet::::new(); + keys.insert(evdev::KeyCode::BTN_LEFT); + let abs = evdev::AbsInfo::new(0, 0, 1000, 0, 0, 0); + let mut vdev = evdev::uinput::VirtualDevice::builder() + .ok()? + .name(name) + .with_keys(&keys) + .ok()? + .with_absolute_axis(&evdev::UinputAbsSetup::new(evdev::AbsoluteAxisCode::ABS_X, abs)) + .ok()? + .with_absolute_axis(&evdev::UinputAbsSetup::new(evdev::AbsoluteAxisCode::ABS_Y, abs)) + .ok()? + .build() + .ok()?; + let dev = open_virtual_device(&mut vdev)?; + Some((vdev, dev)) + } + #[test] #[serial] fn relative_events_emit_button_motion_and_wheel_packets() { @@ -293,4 +314,144 @@ mod mouse_contract { agg.set_btn(0, 0); assert_eq!(agg.buttons & 0b11, 0b10); } + + #[test] + #[serial] + fn absolute_motion_ignores_large_jumps_without_touch_state() { + let Some((mut vdev, dev)) = build_absolute_mouse_without_touch("lesavka-include-abs-jump") + else { + return; + }; + let (tx, _rx) = tokio::sync::broadcast::channel(8); + let mut agg = MouseAggregator::new(dev, false, tx); + + agg.last_abs_x = Some(0); + agg.last_abs_y = Some(0); + assert!(!agg.has_touch_state); + + vdev.emit(&[ + evdev::InputEvent::new( + evdev::EventType::ABSOLUTE.0, + evdev::AbsoluteAxisCode::ABS_X.0, + 900, + ), + evdev::InputEvent::new( + evdev::EventType::ABSOLUTE.0, + evdev::AbsoluteAxisCode::ABS_Y.0, + 900, + ), + ]) + .expect("emit large abs jump"); + + thread::sleep(std::time::Duration::from_millis(20)); + agg.process_events(); + + assert_eq!(agg.dx, 0); + assert_eq!(agg.dy, 0); + assert_eq!(agg.last_abs_x, Some(900)); + assert_eq!(agg.last_abs_y, Some(900)); + } + + #[test] + #[serial] + fn absolute_motion_applies_scaled_delta_within_threshold() { + let Some((mut vdev, dev)) = build_absolute_mouse_without_touch("lesavka-include-abs-delta") + else { + return; + }; + let (tx, _rx) = tokio::sync::broadcast::channel(8); + let mut agg = MouseAggregator::new(dev, false, tx); + agg.last_abs_x = Some(100); + agg.last_abs_y = Some(100); + + vdev.emit(&[ + evdev::InputEvent::new( + evdev::EventType::ABSOLUTE.0, + evdev::AbsoluteAxisCode::ABS_X.0, + 140, + ), + evdev::InputEvent::new( + evdev::EventType::ABSOLUTE.0, + evdev::AbsoluteAxisCode::ABS_Y.0, + 180, + ), + ]) + .expect("emit small abs delta"); + + thread::sleep(std::time::Duration::from_millis(20)); + agg.process_events(); + + assert_ne!(agg.dx, 0); + assert_ne!(agg.dy, 0); + } + + #[test] + #[serial] + fn touch_guarded_inactive_abs_events_only_update_origins() { + let Some((mut vdev, dev)) = build_touch_device("lesavka-include-touch-guarded") else { + return; + }; + + let (tx, _rx) = tokio::sync::broadcast::channel(8); + let mut agg = MouseAggregator::new(dev, false, tx); + agg.touch_guarded = true; + agg.touch_active = false; + agg.last_abs_x = Some(10); + agg.last_abs_y = Some(10); + + vdev.emit(&[ + evdev::InputEvent::new( + evdev::EventType::ABSOLUTE.0, + evdev::AbsoluteAxisCode::ABS_X.0, + 50, + ), + evdev::InputEvent::new( + evdev::EventType::ABSOLUTE.0, + evdev::AbsoluteAxisCode::ABS_Y.0, + 70, + ), + ]) + .expect("emit guarded abs updates"); + + thread::sleep(std::time::Duration::from_millis(20)); + agg.process_events(); + + assert_eq!(agg.dx, 0); + assert_eq!(agg.dy, 0); + assert_eq!(agg.last_abs_x, Some(50)); + assert_eq!(agg.last_abs_y, Some(70)); + } + + #[test] + #[serial] + fn flush_skips_when_state_unchanged_before_interval_deadline() { + let Some(dev) = open_any_mouse_device() + .or_else(|| build_relative_mouse("lesavka-include-mouse-skip").map(|(_, dev)| dev)) + else { + return; + }; + let (tx, mut rx) = tokio::sync::broadcast::channel(8); + let mut agg = MouseAggregator::new(dev, false, tx); + agg.buttons = 1; + agg.last_buttons = 1; + agg.next_send = std::time::Instant::now() + std::time::Duration::from_millis(50); + + agg.flush(); + assert!(rx.try_recv().is_err()); + } + + #[test] + #[serial] + fn drop_emits_shutdown_packet() { + let Some(dev) = open_any_mouse_device() + .or_else(|| build_relative_mouse("lesavka-include-mouse-drop").map(|(_, dev)| dev)) + else { + return; + }; + let (tx, mut rx) = tokio::sync::broadcast::channel(8); + let agg = MouseAggregator::new(dev, false, tx); + drop(agg); + let pkt = rx.try_recv().expect("drop packet"); + assert_eq!(pkt.data, vec![0; 8]); + } }