//! Integration coverage for client mouse aggregator internals. //! //! Scope: include mouse input source and validate relative/absolute event //! handling, flush behavior, and threshold helpers. //! Targets: `client/src/input/mouse.rs`. //! Why: mouse state transitions are regress-prone and must remain deterministic //! under synthetic device traffic. #[allow(warnings)] mod mouse_contract { include!(env!("LESAVKA_CLIENT_MOUSE_SRC")); use serial_test::serial; use std::path::PathBuf; use std::thread; fn open_virtual_node(vdev: &mut evdev::uinput::VirtualDevice) -> Option { for _ in 0..40 { if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() { if let Some(Ok(path)) = nodes.next() { return Some(path); } } thread::sleep(std::time::Duration::from_millis(10)); } None } fn open_virtual_device(vdev: &mut evdev::uinput::VirtualDevice) -> Option { let node = open_virtual_node(vdev)?; let dev = evdev::Device::open(node).ok()?; dev.set_nonblocking(true).ok()?; Some(dev) } fn open_any_mouse_device() -> Option { let entries = std::fs::read_dir("/dev/input").ok()?; for entry in entries.flatten() { let path = entry.path(); let name = path.file_name()?.to_string_lossy(); if !name.starts_with("event") { continue; } let dev = evdev::Device::open(path).ok()?; let _ = dev.set_nonblocking(true); let rel_mouse = dev .supported_relative_axes() .map(|axes| { axes.contains(evdev::RelativeAxisCode::REL_X) && axes.contains(evdev::RelativeAxisCode::REL_Y) }) .unwrap_or(false) && dev .supported_keys() .map(|keys| { keys.contains(evdev::KeyCode::BTN_LEFT) || keys.contains(evdev::KeyCode::BTN_RIGHT) }) .unwrap_or(false); let abs_touch = dev .supported_absolute_axes() .map(|axes| { axes.contains(evdev::AbsoluteAxisCode::ABS_X) || axes.contains(evdev::AbsoluteAxisCode::ABS_MT_POSITION_X) }) .unwrap_or(false); if rel_mouse || abs_touch { return Some(dev); } } None } fn build_relative_mouse(name: &str) -> Option<(evdev::uinput::VirtualDevice, evdev::Device)> { let mut keys = evdev::AttributeSet::::new(); keys.insert(evdev::KeyCode::BTN_LEFT); keys.insert(evdev::KeyCode::BTN_RIGHT); keys.insert(evdev::KeyCode::BTN_MIDDLE); let mut rel = evdev::AttributeSet::::new(); rel.insert(evdev::RelativeAxisCode::REL_X); rel.insert(evdev::RelativeAxisCode::REL_Y); rel.insert(evdev::RelativeAxisCode::REL_WHEEL); let mut vdev = evdev::uinput::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 build_touch_device(name: &str) -> Option<(evdev::uinput::VirtualDevice, evdev::Device)> { let mut keys = evdev::AttributeSet::::new(); keys.insert(evdev::KeyCode::BTN_TOUCH); 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()? .with_absolute_axis(&evdev::UinputAbsSetup::new( evdev::AbsoluteAxisCode::ABS_MT_TRACKING_ID, evdev::AbsInfo::new(0, -1, 10, 0, 0, 0), )) .ok()? .build() .ok()?; let dev = open_virtual_device(&mut vdev)?; 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() { if cfg!(coverage) { return; } let Some((mut vdev, dev)) = build_relative_mouse("lesavka-include-mouse-rel") else { return; }; let (tx, mut rx) = tokio::sync::broadcast::channel(32); let mut agg = MouseAggregator::new(dev, true, tx); agg.set_grab(false); agg.set_send(true); vdev.emit(&[ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::BTN_LEFT.0, 1), evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::BTN_RIGHT.0, 1), evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::BTN_MIDDLE.0, 1), evdev::InputEvent::new( evdev::EventType::RELATIVE.0, evdev::RelativeAxisCode::REL_X.0, 11, ), evdev::InputEvent::new( evdev::EventType::RELATIVE.0, evdev::RelativeAxisCode::REL_Y.0, -7, ), evdev::InputEvent::new( evdev::EventType::RELATIVE.0, evdev::RelativeAxisCode::REL_WHEEL.0, 1, ), ]) .expect("emit relative frame"); thread::sleep(std::time::Duration::from_millis(20)); agg.process_events(); let pkt = rx.try_recv().expect("mouse packet"); assert_eq!(pkt.data[0] & 0b0000_0111, 0b0000_0111); assert_eq!(pkt.data[1], 11); assert_eq!(pkt.data[2], (-7_i8) as u8); assert_eq!(pkt.data[3], 1); } #[test] #[serial] fn touch_tracking_updates_touch_state_and_clears_abs_origins_on_release() { if cfg!(coverage) { return; } let Some((mut vdev, dev)) = build_touch_device("lesavka-include-mouse-touch") else { return; }; let (tx, _rx) = tokio::sync::broadcast::channel(8); let mut agg = MouseAggregator::new(dev, true, tx); vdev.emit(&[ evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::BTN_TOUCH.0, 1), evdev::InputEvent::new( evdev::EventType::ABSOLUTE.0, evdev::AbsoluteAxisCode::ABS_X.0, 100, ), evdev::InputEvent::new( evdev::EventType::ABSOLUTE.0, evdev::AbsoluteAxisCode::ABS_Y.0, 120, ), evdev::InputEvent::new( evdev::EventType::ABSOLUTE.0, evdev::AbsoluteAxisCode::ABS_MT_TRACKING_ID.0, 1, ), ]) .expect("emit touch start"); thread::sleep(std::time::Duration::from_millis(20)); agg.process_events(); assert!(agg.touch_guarded); assert!(agg.touch_active); vdev.emit(&[evdev::InputEvent::new( evdev::EventType::ABSOLUTE.0, evdev::AbsoluteAxisCode::ABS_MT_TRACKING_ID.0, -1, )]) .expect("emit touch end"); thread::sleep(std::time::Duration::from_millis(20)); agg.process_events(); assert!(agg.touch_guarded); assert!(!agg.touch_active); assert!(agg.last_abs_x.is_none()); assert!(agg.last_abs_y.is_none()); } #[test] #[serial] fn flush_and_reset_state_honor_send_toggle_and_clear_accumulators() { let Some(dev) = open_any_mouse_device() .or_else(|| build_relative_mouse("lesavka-include-mouse-flush").map(|(_, dev)| dev)) else { return; }; let (tx, mut rx) = tokio::sync::broadcast::channel(32); let mut agg = MouseAggregator::new(dev, false, tx); agg.buttons = 1; agg.last_buttons = 0; agg.dx = 7; agg.dy = -3; agg.wheel = 1; agg.next_send = std::time::Instant::now() - std::time::Duration::from_millis(10); agg.set_send(false); agg.flush(); assert!( rx.try_recv().is_err(), "send-disabled flush should not emit" ); agg.buttons = 2; agg.last_buttons = 0; agg.dx = 4; agg.dy = -2; agg.wheel = -1; agg.next_send = std::time::Instant::now() - std::time::Duration::from_millis(10); agg.set_send(true); agg.flush(); let pkt = rx.try_recv().expect("flush packet"); assert_eq!(pkt.data[0], 2); assert_eq!(pkt.data[1], 4); assert_eq!(pkt.data[2], (-2_i8) as u8); assert_eq!(pkt.data[3], (-1_i8) as u8); assert_eq!(agg.dx, 0); assert_eq!(agg.dy, 0); assert_eq!(agg.wheel, 0); agg.reset_state(); let reset_pkt = rx.try_recv().expect("reset packet"); assert_eq!(reset_pkt.data, vec![0, 0, 0, 0]); } #[test] #[serial] fn abs_jump_threshold_uses_scale_based_minimum_without_absinfo() { let Some(dev) = open_any_mouse_device().or_else(|| { build_relative_mouse("lesavka-include-mouse-threshold").map(|(_, dev)| dev) }) else { return; }; let threshold = MouseAggregator::abs_jump_threshold(&dev, &[evdev::AbsoluteAxisCode::ABS_X], 3); assert!( threshold >= 120, "threshold should honor scale-derived minimum" ); } #[test] #[serial] fn set_btn_toggles_expected_button_bits() { let Some(dev) = open_any_mouse_device() .or_else(|| build_relative_mouse("lesavka-include-mouse-bits").map(|(_, dev)| dev)) else { return; }; let (tx, _rx) = tokio::sync::broadcast::channel(8); let mut agg = MouseAggregator::new(dev, false, tx); agg.set_btn(0, 1); agg.set_btn(1, 1); assert_eq!(agg.buttons & 0b11, 0b11); agg.set_btn(0, 0); assert_eq!(agg.buttons & 0b11, 0b10); } #[test] #[serial] fn absolute_motion_ignores_large_jumps_without_touch_state() { if cfg!(coverage) { return; } 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() { if cfg!(coverage) { return; } 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() { if cfg!(coverage) { return; } 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]); } }