From 23dcaf8263b64a56b805c13dd83b48344f952881 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 12 Apr 2026 19:47:26 -0300 Subject: [PATCH] testing: add input classifier and mouse uinput contracts --- testing/Cargo.toml | 6 + testing/build.rs | 24 +++ testing/tests/client_inputs_contract.rs | 92 +++++++++ testing/tests/client_mouse_uinput_contract.rs | 174 ++++++++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 testing/tests/client_inputs_contract.rs create mode 100644 testing/tests/client_mouse_uinput_contract.rs diff --git a/testing/Cargo.toml b/testing/Cargo.toml index 195c566..e4f2f6c 100644 --- a/testing/Cargo.toml +++ b/testing/Cargo.toml @@ -12,6 +12,7 @@ path = "src/lib.rs" [dev-dependencies] anyhow = "1.0" evdev = "0.13" +futures-util = "0.3" libc = "0.2" lesavka_client = { path = "../client" } lesavka_common = { path = "../common" } @@ -22,4 +23,9 @@ serial_test = { workspace = true } temp-env = { workspace = true } tempfile = { workspace = true } tokio = { version = "1.45", features = ["full", "macros", "rt-multi-thread", "sync", "time"] } +tokio-stream = "0.1" tonic = { version = "0.13", features = ["transport"] } +tonic-reflection = "0.13" +tracing = "0.1" +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "ansi"] } diff --git a/testing/build.rs b/testing/build.rs index 0972f79..76eb72a 100644 --- a/testing/build.rs +++ b/testing/build.rs @@ -8,6 +8,18 @@ fn main() { .join("server/src/bin/lesavka-uvc.rs") .canonicalize() .expect("canonical server uvc bin path"); + let server_main = workspace_dir + .join("server/src/main.rs") + .canonicalize() + .expect("canonical server main path"); + let client_main = workspace_dir + .join("client/src/main.rs") + .canonicalize() + .expect("canonical client main path"); + let client_inputs = workspace_dir + .join("client/src/input/inputs.rs") + .canonicalize() + .expect("canonical client inputs path"); let common_cli = workspace_dir .join("common/src/bin/cli.rs") .canonicalize() @@ -17,6 +29,18 @@ fn main() { "cargo:rustc-env=LESAVKA_SERVER_UVC_BIN_SRC={}", server_uvc.display() ); + println!( + "cargo:rustc-env=LESAVKA_SERVER_MAIN_SRC={}", + server_main.display() + ); + println!( + "cargo:rustc-env=LESAVKA_CLIENT_MAIN_SRC={}", + client_main.display() + ); + println!( + "cargo:rustc-env=LESAVKA_CLIENT_INPUTS_SRC={}", + client_inputs.display() + ); println!( "cargo:rustc-env=LESAVKA_COMMON_CLI_BIN_SRC={}", common_cli.display() diff --git a/testing/tests/client_inputs_contract.rs b/testing/tests/client_inputs_contract.rs new file mode 100644 index 0000000..2e1bf17 --- /dev/null +++ b/testing/tests/client_inputs_contract.rs @@ -0,0 +1,92 @@ +//! Integration coverage for client input-device classification helpers. +//! +//! Scope: include the input aggregator source and exercise private device +//! classification against synthetic uinput keyboard/mouse devices. +//! Targets: `client/src/input/inputs.rs`. +//! Why: device classification regressions can silently break all input capture +//! at runtime, so classifier behavior should stay under contract. + +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 { + 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() -> Option { + let mut keys = AttributeSet::::new(); + keys.insert(evdev::KeyCode::KEY_A); + keys.insert(evdev::KeyCode::KEY_ENTER); + + let mut vdev = VirtualDevice::builder() + .ok()? + .name("lesavka-input-classify-kbd") + .with_keys(&keys) + .ok()? + .build() + .ok()?; + + open_virtual_device(&mut vdev) + } + + fn build_mouse() -> Option { + 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("lesavka-input-classify-mouse") + .with_keys(&keys) + .ok()? + .with_relative_axes(&rel) + .ok()? + .build() + .ok()?; + + open_virtual_device(&mut vdev) + } + + #[test] + #[serial] + fn classify_device_recognizes_keyboard_and_mouse_capabilities() { + if let Some(kbd) = build_keyboard() { + assert!(matches!(classify_device(&kbd), DeviceKind::Keyboard)); + } + + if let Some(mouse) = build_mouse() { + assert!(matches!(classify_device(&mouse), DeviceKind::Mouse)); + } + } +} diff --git a/testing/tests/client_mouse_uinput_contract.rs b/testing/tests/client_mouse_uinput_contract.rs new file mode 100644 index 0000000..bf2d5ce --- /dev/null +++ b/testing/tests/client_mouse_uinput_contract.rs @@ -0,0 +1,174 @@ +//! Integration coverage for the client mouse input contract using uinput. +//! +//! Scope: exercise `MouseAggregator` with synthetic relative and absolute input +//! devices so event translation stays deterministic. +//! Targets: `client/src/input/mouse.rs`. +//! Why: mouse handling is event-rich and high-risk for regressions without +//! end-to-end event-path tests. + +use evdev::uinput::VirtualDevice; +use evdev::{ + AbsInfo, AbsoluteAxisCode, AttributeSet, Device, EventType, InputEvent, KeyCode, + RelativeAxisCode, UinputAbsSetup, +}; +use lesavka_client::input::mouse::MouseAggregator; +use serial_test::serial; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; +use tokio::sync::broadcast; + +fn open_virtual_node(vdev: &mut VirtualDevice) -> Option { + let mut node = None; + for _ in 0..40 { + if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() { + if let Some(Ok(path)) = nodes.next() { + node = Some(path); + break; + } + } + thread::sleep(Duration::from_millis(10)); + } + node +} + +fn open_virtual_device(vdev: &mut VirtualDevice) -> Option { + let node = open_virtual_node(vdev)?; + let dev = Device::open(node).ok()?; + dev.set_nonblocking(true).ok()?; + Some(dev) +} + +fn build_relative_mouse(name: &str) -> Option<(VirtualDevice, Device)> { + let mut keys = AttributeSet::::new(); + keys.insert(KeyCode::BTN_LEFT); + keys.insert(KeyCode::BTN_RIGHT); + + let mut rel = AttributeSet::::new(); + rel.insert(RelativeAxisCode::REL_X); + rel.insert(RelativeAxisCode::REL_Y); + rel.insert(RelativeAxisCode::REL_WHEEL); + + 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 build_absolute_touch_mouse(name: &str) -> Option<(VirtualDevice, Device)> { + let mut keys = AttributeSet::::new(); + keys.insert(KeyCode::BTN_TOUCH); + + let abs = AbsInfo::new(0, 0, 1000, 0, 0, 0); + let mut vdev = VirtualDevice::builder() + .ok()? + .name(name) + .with_keys(&keys) + .ok()? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_X, abs)) + .ok()? + .with_absolute_axis(&UinputAbsSetup::new(AbsoluteAxisCode::ABS_Y, abs)) + .ok()? + .with_absolute_axis(&UinputAbsSetup::new( + AbsoluteAxisCode::ABS_MT_TRACKING_ID, + AbsInfo::new(0, -1, 10, 0, 0, 0), + )) + .ok()? + .build() + .ok()?; + + let dev = open_virtual_device(&mut vdev)?; + Some((vdev, dev)) +} + +#[test] +#[serial] +fn relative_mouse_events_emit_expected_packets() { + let Some((mut vdev, dev)) = build_relative_mouse("lesavka-test-mouse-rel") else { + return; + }; + + let (tx, mut rx) = broadcast::channel(32); + let mut agg = MouseAggregator::new(dev, true, tx); + + agg.set_grab(false); + agg.set_send(true); + + vdev.emit(&[ + InputEvent::new(EventType::KEY.0, KeyCode::BTN_LEFT.0, 1), + InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_X.0, 12), + InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_Y.0, -5), + InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_WHEEL.0, 1), + ]) + .expect("emit relative press/move events"); + + thread::sleep(Duration::from_millis(25)); + agg.process_events(); + + let pkt = rx.try_recv().expect("mouse report"); + assert_eq!(pkt.data[0], 1); + assert_eq!(pkt.data[1], 12); + assert_eq!(pkt.data[2], (-5_i8) as u8); + assert_eq!(pkt.data[3], 1); + + vdev.emit(&[InputEvent::new(EventType::KEY.0, KeyCode::BTN_LEFT.0, 0)]) + .expect("emit button release"); + thread::sleep(Duration::from_millis(25)); + agg.process_events(); + + let release_pkt = rx.try_recv().expect("release report"); + assert_eq!(release_pkt.data[0], 0); +} + +#[test] +#[serial] +fn absolute_touch_events_translate_into_motion_and_reset() { + let Some((mut vdev, dev)) = build_absolute_touch_mouse("lesavka-test-mouse-abs") else { + return; + }; + + let (tx, mut rx) = broadcast::channel(32); + let mut agg = MouseAggregator::new(dev, true, tx); + + vdev.emit(&[ + InputEvent::new(EventType::KEY.0, KeyCode::BTN_TOUCH.0, 1), + InputEvent::new(EventType::ABSOLUTE.0, AbsoluteAxisCode::ABS_X.0, 100), + InputEvent::new(EventType::ABSOLUTE.0, AbsoluteAxisCode::ABS_Y.0, 100), + ]) + .expect("emit initial touch frame"); + thread::sleep(Duration::from_millis(20)); + agg.process_events(); + + vdev.emit(&[ + InputEvent::new(EventType::ABSOLUTE.0, AbsoluteAxisCode::ABS_X.0, 300), + InputEvent::new(EventType::ABSOLUTE.0, AbsoluteAxisCode::ABS_Y.0, 500), + ]) + .expect("emit movement frame"); + thread::sleep(Duration::from_millis(20)); + agg.process_events(); + + let pkt = rx.try_recv().expect("absolute movement report"); + assert_eq!(pkt.data[0], 1); + assert_ne!(pkt.data[1], 0); + assert_ne!(pkt.data[2], 0); + + agg.set_send(false); + agg.reset_state(); + assert!( + rx.try_recv().is_err(), + "send-disabled reset should not emit" + ); + + agg.set_send(true); + agg.reset_state(); + let reset_pkt = rx.try_recv().expect("reset report"); + assert_eq!(reset_pkt.data, vec![0, 0, 0, 0]); +}