From ed60b3e0ba558277c1606478e37d8e07fda0b215 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 12 Apr 2026 20:45:31 -0300 Subject: [PATCH] testing: add keyboard and mouse include coverage contracts --- testing/build.rs | 16 + .../tests/client_keyboard_include_contract.rs | 272 ++++++++++++++++ .../tests/client_mouse_include_contract.rs | 296 ++++++++++++++++++ 3 files changed, 584 insertions(+) create mode 100644 testing/tests/client_keyboard_include_contract.rs create mode 100644 testing/tests/client_mouse_include_contract.rs diff --git a/testing/build.rs b/testing/build.rs index 76eb72a..fe3400b 100644 --- a/testing/build.rs +++ b/testing/build.rs @@ -20,6 +20,14 @@ fn main() { .join("client/src/input/inputs.rs") .canonicalize() .expect("canonical client inputs path"); + let client_keyboard = workspace_dir + .join("client/src/input/keyboard.rs") + .canonicalize() + .expect("canonical client keyboard path"); + let client_mouse = workspace_dir + .join("client/src/input/mouse.rs") + .canonicalize() + .expect("canonical client mouse path"); let common_cli = workspace_dir .join("common/src/bin/cli.rs") .canonicalize() @@ -41,6 +49,14 @@ fn main() { "cargo:rustc-env=LESAVKA_CLIENT_INPUTS_SRC={}", client_inputs.display() ); + println!( + "cargo:rustc-env=LESAVKA_CLIENT_KEYBOARD_SRC={}", + client_keyboard.display() + ); + println!( + "cargo:rustc-env=LESAVKA_CLIENT_MOUSE_SRC={}", + client_mouse.display() + ); println!( "cargo:rustc-env=LESAVKA_COMMON_CLI_BIN_SRC={}", common_cli.display() diff --git a/testing/tests/client_keyboard_include_contract.rs b/testing/tests/client_keyboard_include_contract.rs new file mode 100644 index 0000000..0300453 --- /dev/null +++ b/testing/tests/client_keyboard_include_contract.rs @@ -0,0 +1,272 @@ +//! Integration coverage for client keyboard aggregator internals. +//! +//! Scope: include keyboard input source and validate report shaping, magic +//! chords, paste handling, and event processing against synthetic keyboards. +//! Targets: `client/src/input/keyboard.rs`. +//! Why: keyboard chord and paste logic is stateful and needs direct branch +//! coverage to avoid regressions. + +mod keymap { + pub use lesavka_client::input::keymap::*; +} + +#[allow(warnings)] +mod keyboard_contract { + include!(env!("LESAVKA_CLIENT_KEYBOARD_SRC")); + + use serial_test::serial; + use std::thread; + use temp_env::with_var; + + fn open_virtual_device(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() { + let dev = evdev::Device::open(path).ok()?; + dev.set_nonblocking(true).ok()?; + return Some(dev); + } + } + thread::sleep(std::time::Duration::from_millis(10)); + } + None + } + + fn open_any_keyboard_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 looks_like_keyboard = dev + .supported_keys() + .map(|keys| { + keys.contains(evdev::KeyCode::KEY_A) + && keys.contains(evdev::KeyCode::KEY_ENTER) + && keys.contains(evdev::KeyCode::KEY_LEFTCTRL) + }) + .unwrap_or(false); + if looks_like_keyboard { + return Some(dev); + } + } + None + } + + fn build_keyboard(name: &str) -> Option<(evdev::uinput::VirtualDevice, evdev::Device)> { + let mut keys = evdev::AttributeSet::::new(); + keys.insert(evdev::KeyCode::KEY_A); + keys.insert(evdev::KeyCode::KEY_B); + keys.insert(evdev::KeyCode::KEY_C); + keys.insert(evdev::KeyCode::KEY_D); + keys.insert(evdev::KeyCode::KEY_E); + keys.insert(evdev::KeyCode::KEY_F); + keys.insert(evdev::KeyCode::KEY_G); + keys.insert(evdev::KeyCode::KEY_V); + keys.insert(evdev::KeyCode::KEY_LEFTCTRL); + keys.insert(evdev::KeyCode::KEY_LEFTALT); + keys.insert(evdev::KeyCode::KEY_LEFTSHIFT); + keys.insert(evdev::KeyCode::KEY_ESC); + keys.insert(evdev::KeyCode::KEY_LEFT); + keys.insert(evdev::KeyCode::KEY_RIGHT); + + let mut vdev = evdev::uinput::VirtualDevice::builder() + .ok()? + .name(name) + .with_keys(&keys) + .ok()? + .build() + .ok()?; + let dev = open_virtual_device(&mut vdev)?; + Some((vdev, dev)) + } + + fn new_aggregator( + dev: evdev::Device, + ) -> ( + KeyboardAggregator, + tokio::sync::broadcast::Receiver, + ) { + let (tx, rx) = tokio::sync::broadcast::channel(128); + (KeyboardAggregator::new(dev, true, tx, None), rx) + } + + #[test] + #[serial] + fn process_events_emits_press_and_release_reports() { + let Some((mut vdev, dev)) = build_keyboard("lesavka-include-kbd-events") else { + return; + }; + let (mut agg, mut rx) = new_aggregator(dev); + + vdev.emit(&[evdev::InputEvent::new( + evdev::EventType::KEY.0, + evdev::KeyCode::KEY_A.0, + 1, + )]) + .expect("emit key press"); + thread::sleep(std::time::Duration::from_millis(20)); + agg.process_events(); + let press = rx.try_recv().expect("press report"); + assert_ne!(press.data[2], 0); + + vdev.emit(&[evdev::InputEvent::new( + evdev::EventType::KEY.0, + evdev::KeyCode::KEY_A.0, + 0, + )]) + .expect("emit key release"); + thread::sleep(std::time::Duration::from_millis(20)); + agg.process_events(); + let release = rx.try_recv().expect("release report"); + assert_eq!(release.data[2], 0); + } + + #[test] + #[serial] + fn build_report_sets_modifiers_and_limits_to_six_keys() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-report").map(|(_, dev)| dev)) + else { + return; + }; + let (mut agg, _) = new_aggregator(dev); + + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); + for key in [ + evdev::KeyCode::KEY_A, + evdev::KeyCode::KEY_B, + evdev::KeyCode::KEY_C, + evdev::KeyCode::KEY_D, + evdev::KeyCode::KEY_E, + evdev::KeyCode::KEY_F, + evdev::KeyCode::KEY_G, + ] { + agg.pressed_keys.insert(key); + } + + let report = agg.build_report(); + assert_ne!(report[0], 0); + assert!(report[2..].iter().filter(|value| **value != 0).count() <= 6); + } + + #[test] + #[serial] + fn magic_chords_track_expected_combinations() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-magic").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_LEFTSHIFT); + agg.pressed_keys.insert(evdev::KeyCode::KEY_G); + assert!(agg.magic_grab()); + + agg.pressed_keys.remove(&evdev::KeyCode::KEY_G); + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFT); + assert!(agg.magic_left()); + + agg.pressed_keys.remove(&evdev::KeyCode::KEY_LEFT); + agg.pressed_keys.insert(evdev::KeyCode::KEY_RIGHT); + assert!(agg.magic_right()); + + agg.pressed_keys.clear(); + agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL); + agg.pressed_keys.insert(evdev::KeyCode::KEY_ESC); + assert!(agg.magic_kill()); + } + + #[test] + fn paste_rpc_enabled_contract_requires_flag_and_key() { + assert!(!paste_rpc_enabled(false, true)); + assert!(!paste_rpc_enabled(true, false)); + assert!(paste_rpc_enabled(true, true)); + assert!(is_paste_modifier(evdev::KeyCode::KEY_LEFTCTRL)); + assert!(is_paste_modifier(evdev::KeyCode::KEY_RIGHTALT)); + assert!(!is_paste_modifier(evdev::KeyCode::KEY_A)); + } + + #[test] + #[serial] + fn try_handle_paste_event_consumes_chord_and_sends_rpc_payload() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-paste-rpc").map(|(_, dev)| dev)) + else { + return; + }; + let (paste_tx, mut rx_rpc) = tokio::sync::mpsc::unbounded_channel::(); + let (kbd_tx, _rx) = tokio::sync::broadcast::channel(128); + let mut agg = KeyboardAggregator::new(dev, true, kbd_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 'hello-from-clipboard'"), || { + 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: String = rx_rpc.try_recv().expect("rpc payload"); + assert!(payload.contains("hello-from-clipboard")); + assert!(agg.paste_chord_consumed); + + assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 0)); + assert!(!agg.paste_chord_consumed); + } + + #[test] + #[serial] + fn paste_clipboard_emits_hid_reports_for_supported_chars() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-paste-hid").map(|(_, dev)| dev)) + else { + return; + }; + let (mut agg, mut rx) = new_aggregator(dev); + agg.paste_enabled = true; + + with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'ab'"), || { + with_var("LESAVKA_CLIPBOARD_MAX", Some("8"), || { + with_var("LESAVKA_CLIPBOARD_DELAY_MS", Some("0"), || { + agg.paste_clipboard(); + }); + }); + }); + + let mut seen = 0usize; + while rx.try_recv().is_ok() { + seen += 1; + } + assert!(seen >= 2, "expected multiple key reports for pasted characters"); + } + + #[test] + #[serial] + fn reset_state_clears_pressed_keys_and_emits_empty_report() { + let Some(dev) = open_any_keyboard_device() + .or_else(|| build_keyboard("lesavka-include-kbd-reset").map(|(_, dev)| dev)) + else { + return; + }; + let (mut agg, mut rx) = new_aggregator(dev); + + agg.pressed_keys.insert(evdev::KeyCode::KEY_A); + agg.reset_state(); + assert!(agg.pressed_keys.is_empty()); + let pkt = rx.try_recv().expect("empty report after reset"); + 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 new file mode 100644 index 0000000..821d57c --- /dev/null +++ b/testing/tests/client_mouse_include_contract.rs @@ -0,0 +1,296 @@ +//! 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)) + } + + #[test] + #[serial] + fn relative_events_emit_button_motion_and_wheel_packets() { + 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() { + 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); + } +}