testing: add keyboard and mouse include coverage contracts

This commit is contained in:
Brad Stein 2026-04-12 20:45:31 -03:00
parent 28d490c5eb
commit ed60b3e0ba
3 changed files with 584 additions and 0 deletions

View File

@ -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()

View File

@ -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<evdev::Device> {
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<evdev::Device> {
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::<evdev::KeyCode>::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<KeyboardReport>,
) {
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::<String>();
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]);
}
}

View File

@ -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<PathBuf> {
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<evdev::Device> {
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<evdev::Device> {
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::<evdev::KeyCode>::new();
keys.insert(evdev::KeyCode::BTN_LEFT);
keys.insert(evdev::KeyCode::BTN_RIGHT);
keys.insert(evdev::KeyCode::BTN_MIDDLE);
let mut rel = evdev::AttributeSet::<evdev::RelativeAxisCode>::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::<evdev::KeyCode>::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);
}
}