2026-04-16 12:58:05 -03:00
|
|
|
//! Extra include-based coverage for input aggregator edge cases.
|
|
|
|
|
//!
|
|
|
|
|
//! Scope: keep additional quick-toggle regression checks in a separate file so
|
|
|
|
|
//! each testing module stays under the 500 LOC contract.
|
|
|
|
|
//! Targets: `client/src/input/inputs.rs`.
|
|
|
|
|
//! Why: quick swap-key taps can otherwise disappear inside one poll cycle and
|
|
|
|
|
//! make local/remote handoff feel flaky in the live launcher path.
|
|
|
|
|
|
|
|
|
|
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_extra {
|
|
|
|
|
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<evdev::Device> {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:07:25 -03:00
|
|
|
fn build_keyboard_pair_with_keys(
|
|
|
|
|
name: &str,
|
|
|
|
|
keycodes: &[evdev::KeyCode],
|
|
|
|
|
) -> Option<(VirtualDevice, evdev::Device)> {
|
2026-04-16 12:58:05 -03:00
|
|
|
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
2026-04-16 15:07:25 -03:00
|
|
|
for keycode in keycodes {
|
|
|
|
|
keys.insert(*keycode);
|
|
|
|
|
}
|
2026-04-16 12:58:05 -03:00
|
|
|
|
|
|
|
|
let mut vdev = VirtualDevice::builder()
|
|
|
|
|
.ok()?
|
|
|
|
|
.name(name)
|
|
|
|
|
.with_keys(&keys)
|
|
|
|
|
.ok()?
|
|
|
|
|
.build()
|
|
|
|
|
.ok()?;
|
|
|
|
|
|
|
|
|
|
let dev = open_virtual_device(&mut vdev)?;
|
|
|
|
|
Some((vdev, dev))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:07:25 -03:00
|
|
|
fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
|
|
|
|
|
build_keyboard_pair_with_keys(name, &[evdev::KeyCode::KEY_A, evdev::KeyCode::KEY_ENTER])
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 07:00:06 -03:00
|
|
|
fn build_mouse_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
|
|
|
|
|
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
|
|
|
|
keys.insert(evdev::KeyCode::BTN_LEFT);
|
|
|
|
|
let mut rel = AttributeSet::<evdev::RelativeAxisCode>::new();
|
|
|
|
|
rel.insert(evdev::RelativeAxisCode::REL_X);
|
|
|
|
|
rel.insert(evdev::RelativeAxisCode::REL_Y);
|
|
|
|
|
|
|
|
|
|
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 new_aggregator_with_capture(capture_remote_boot: bool) -> InputAggregator {
|
|
|
|
|
let (kbd_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
InputAggregator::new_with_capture_mode(false, kbd_tx, mou_tx, None, capture_remote_boot)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn init_honors_device_selection_mismatches() {
|
|
|
|
|
let Some((_kbd_vdev, _kbd_dev)) = build_keyboard_pair("lesavka-input-init-skip-kbd") else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let Some((_mouse_vdev, _mouse_dev)) = build_mouse_pair("lesavka-input-init-skip-mouse")
|
|
|
|
|
else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
temp_env::with_vars(
|
|
|
|
|
[
|
|
|
|
|
("LESAVKA_KEYBOARD_DEVICE", Some("/dev/input/does-not-match")),
|
|
|
|
|
("LESAVKA_MOUSE_DEVICE", Some("/dev/input/does-not-match")),
|
|
|
|
|
],
|
|
|
|
|
|| {
|
|
|
|
|
let mut agg = new_aggregator_with_capture(true);
|
|
|
|
|
agg.init().expect("init should tolerate skipped devices");
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn init_stages_devices_ungrabbed_when_session_starts_local() {
|
|
|
|
|
let Some((_kbd_vdev, _kbd_dev)) = build_keyboard_pair("lesavka-input-init-local-kbd")
|
|
|
|
|
else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let Some((_mouse_vdev, _mouse_dev)) = build_mouse_pair("lesavka-input-init-local-mouse")
|
|
|
|
|
else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
temp_env::with_vars(
|
|
|
|
|
[
|
|
|
|
|
("LESAVKA_KEYBOARD_DEVICE", None::<&str>),
|
|
|
|
|
("LESAVKA_MOUSE_DEVICE", None::<&str>),
|
|
|
|
|
],
|
|
|
|
|
|| {
|
|
|
|
|
let mut agg = new_aggregator_with_capture(false);
|
|
|
|
|
agg.init().expect("init should stage local devices");
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn quick_toggle_detects_tap_when_press_and_release_land_in_same_poll_cycle() {
|
|
|
|
|
let Some((mut vdev, dev)) = build_keyboard_pair("lesavka-input-quick-toggle-tap") else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (kbd_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let (agg_kbd_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let (agg_mou_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let mut keyboard = KeyboardAggregator::new(dev, false, kbd_tx, None);
|
|
|
|
|
|
|
|
|
|
vdev.emit(&[
|
|
|
|
|
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1),
|
|
|
|
|
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 0),
|
|
|
|
|
])
|
|
|
|
|
.expect("emit quick-toggle tap");
|
|
|
|
|
thread::sleep(std::time::Duration::from_millis(20));
|
|
|
|
|
keyboard.process_events();
|
|
|
|
|
|
|
|
|
|
let mut agg = InputAggregator::new(false, agg_kbd_tx, agg_mou_tx, None);
|
|
|
|
|
agg.quick_toggle_key = Some(evdev::KeyCode::KEY_A);
|
|
|
|
|
agg.keyboards.push(keyboard);
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
agg.quick_toggle_active(),
|
|
|
|
|
"quick-toggle should fire even when a tap starts and ends inside one poll batch"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!agg.quick_toggle_active(),
|
|
|
|
|
"tap activation should be consumed after one observation"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-16 15:07:25 -03:00
|
|
|
|
2026-04-23 07:00:06 -03:00
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
async fn run_keeps_pending_release_armed_until_tracked_key_is_released() {
|
|
|
|
|
let Some((mut vdev, dev)) =
|
|
|
|
|
build_keyboard_pair_with_keys("lesavka-run-held-key", &[evdev::KeyCode::KEY_A])
|
|
|
|
|
else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (kbd_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let mut keyboard = KeyboardAggregator::new(dev, false, kbd_tx.clone(), None);
|
|
|
|
|
vdev.emit(&[evdev::InputEvent::new(
|
|
|
|
|
evdev::EventType::KEY.0,
|
|
|
|
|
evdev::KeyCode::KEY_A.0,
|
|
|
|
|
1,
|
|
|
|
|
)])
|
|
|
|
|
.expect("emit held key");
|
|
|
|
|
thread::sleep(std::time::Duration::from_millis(25));
|
|
|
|
|
keyboard.process_events();
|
|
|
|
|
|
|
|
|
|
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
|
|
|
|
|
agg.pending_release = true;
|
|
|
|
|
agg.pending_keys.insert(evdev::KeyCode::KEY_A);
|
|
|
|
|
agg.keyboards.push(keyboard);
|
|
|
|
|
|
|
|
|
|
let result = tokio::time::timeout(std::time::Duration::from_millis(40), agg.run()).await;
|
|
|
|
|
assert!(result.is_err(), "held key should keep the run loop active");
|
|
|
|
|
assert!(agg.pending_release);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
async fn run_finishes_pending_release_after_tracked_key_disappears() {
|
|
|
|
|
let (kbd_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
|
|
|
|
|
agg.pending_release = true;
|
|
|
|
|
agg.pending_keys.insert(evdev::KeyCode::KEY_A);
|
|
|
|
|
|
|
|
|
|
let result = tokio::time::timeout(std::time::Duration::from_millis(40), agg.run()).await;
|
|
|
|
|
assert!(
|
|
|
|
|
result.is_err(),
|
|
|
|
|
"run loop should continue after releasing locally"
|
|
|
|
|
);
|
|
|
|
|
assert!(agg.released);
|
|
|
|
|
assert!(!agg.pending_release);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:07:25 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn process_keyboard_updates_merges_modifier_and_key_across_keyboards() {
|
|
|
|
|
let Some((mut shift_vdev, shift_dev)) = build_keyboard_pair_with_keys(
|
|
|
|
|
"lesavka-input-merge-shift",
|
|
|
|
|
&[evdev::KeyCode::KEY_LEFTSHIFT],
|
|
|
|
|
) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let Some((mut a_vdev, a_dev)) =
|
|
|
|
|
build_keyboard_pair_with_keys("lesavka-input-merge-a", &[evdev::KeyCode::KEY_A])
|
|
|
|
|
else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (kbd_tx, mut rx) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let shift_keyboard = KeyboardAggregator::new(shift_dev, false, kbd_tx.clone(), None);
|
|
|
|
|
let a_keyboard = KeyboardAggregator::new(a_dev, false, kbd_tx.clone(), None);
|
|
|
|
|
|
|
|
|
|
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
|
|
|
|
|
agg.keyboards.push(shift_keyboard);
|
|
|
|
|
agg.keyboards.push(a_keyboard);
|
|
|
|
|
|
|
|
|
|
shift_vdev
|
|
|
|
|
.emit(&[evdev::InputEvent::new(
|
|
|
|
|
evdev::EventType::KEY.0,
|
|
|
|
|
evdev::KeyCode::KEY_LEFTSHIFT.0,
|
|
|
|
|
1,
|
|
|
|
|
)])
|
|
|
|
|
.expect("emit shift");
|
|
|
|
|
a_vdev
|
|
|
|
|
.emit(&[evdev::InputEvent::new(
|
|
|
|
|
evdev::EventType::KEY.0,
|
|
|
|
|
evdev::KeyCode::KEY_A.0,
|
|
|
|
|
1,
|
|
|
|
|
)])
|
|
|
|
|
.expect("emit a");
|
|
|
|
|
thread::sleep(std::time::Duration::from_millis(25));
|
|
|
|
|
|
|
|
|
|
temp_env::with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("0"), || {
|
|
|
|
|
agg.process_keyboard_updates();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let reports: Vec<Vec<u8>> =
|
|
|
|
|
std::iter::from_fn(|| rx.try_recv().ok().map(|pkt| pkt.data)).collect();
|
|
|
|
|
assert!(
|
|
|
|
|
reports.contains(&vec![0x02, 0, 0x04, 0, 0, 0, 0, 0]),
|
|
|
|
|
"expected merged shift+A report, got {reports:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:32:15 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn process_keyboard_updates_keeps_quick_shift_chord_when_full_tap_lands_in_one_poll_cycle() {
|
|
|
|
|
let Some((mut vdev, dev)) = build_keyboard_pair_with_keys(
|
|
|
|
|
"lesavka-input-shift-batch",
|
|
|
|
|
&[evdev::KeyCode::KEY_LEFTSHIFT, evdev::KeyCode::KEY_A],
|
|
|
|
|
) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (kbd_tx, mut rx) = tokio::sync::broadcast::channel(32);
|
|
|
|
|
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let keyboard = KeyboardAggregator::new(dev, false, kbd_tx.clone(), None);
|
|
|
|
|
|
|
|
|
|
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
|
|
|
|
|
agg.keyboards.push(keyboard);
|
|
|
|
|
|
|
|
|
|
vdev.emit(&[
|
|
|
|
|
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_LEFTSHIFT.0, 1),
|
|
|
|
|
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1),
|
|
|
|
|
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 0),
|
|
|
|
|
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_LEFTSHIFT.0, 0),
|
|
|
|
|
])
|
|
|
|
|
.expect("emit quick shifted tap");
|
|
|
|
|
thread::sleep(std::time::Duration::from_millis(25));
|
|
|
|
|
|
|
|
|
|
temp_env::with_var("LESAVKA_LIVE_MODIFIER_DELAY_MS", Some("0"), || {
|
|
|
|
|
agg.process_keyboard_updates();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let reports: Vec<Vec<u8>> =
|
|
|
|
|
std::iter::from_fn(|| rx.try_recv().ok().map(|pkt| pkt.data)).collect();
|
|
|
|
|
assert!(
|
|
|
|
|
reports.contains(&vec![0x02, 0, 0x04, 0, 0, 0, 0, 0]),
|
|
|
|
|
"expected shifted A report from one-batch chord, got {reports:?}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
reports.contains(&vec![0; 8]),
|
|
|
|
|
"expected final empty report after one-batch chord, got {reports:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:07:25 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn process_keyboard_updates_keeps_overlapping_plain_keys_from_sticking_across_keyboards() {
|
|
|
|
|
let Some((mut a_vdev, a_dev)) =
|
|
|
|
|
build_keyboard_pair_with_keys("lesavka-input-merge-a-only", &[evdev::KeyCode::KEY_A])
|
|
|
|
|
else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let Some((mut s_vdev, s_dev)) =
|
|
|
|
|
build_keyboard_pair_with_keys("lesavka-input-merge-s-only", &[evdev::KeyCode::KEY_S])
|
|
|
|
|
else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (kbd_tx, mut rx) = tokio::sync::broadcast::channel(32);
|
|
|
|
|
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let a_keyboard = KeyboardAggregator::new(a_dev, false, kbd_tx.clone(), None);
|
|
|
|
|
let s_keyboard = KeyboardAggregator::new(s_dev, false, kbd_tx.clone(), None);
|
|
|
|
|
|
|
|
|
|
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
|
|
|
|
|
agg.keyboards.push(a_keyboard);
|
|
|
|
|
agg.keyboards.push(s_keyboard);
|
|
|
|
|
|
|
|
|
|
a_vdev
|
|
|
|
|
.emit(&[evdev::InputEvent::new(
|
|
|
|
|
evdev::EventType::KEY.0,
|
|
|
|
|
evdev::KeyCode::KEY_A.0,
|
|
|
|
|
1,
|
|
|
|
|
)])
|
|
|
|
|
.expect("emit a down");
|
|
|
|
|
s_vdev
|
|
|
|
|
.emit(&[evdev::InputEvent::new(
|
|
|
|
|
evdev::EventType::KEY.0,
|
|
|
|
|
evdev::KeyCode::KEY_S.0,
|
|
|
|
|
1,
|
|
|
|
|
)])
|
|
|
|
|
.expect("emit s down");
|
|
|
|
|
thread::sleep(std::time::Duration::from_millis(25));
|
|
|
|
|
agg.process_keyboard_updates();
|
|
|
|
|
|
|
|
|
|
a_vdev
|
|
|
|
|
.emit(&[evdev::InputEvent::new(
|
|
|
|
|
evdev::EventType::KEY.0,
|
|
|
|
|
evdev::KeyCode::KEY_A.0,
|
|
|
|
|
0,
|
|
|
|
|
)])
|
|
|
|
|
.expect("emit a up");
|
|
|
|
|
s_vdev
|
|
|
|
|
.emit(&[evdev::InputEvent::new(
|
|
|
|
|
evdev::EventType::KEY.0,
|
|
|
|
|
evdev::KeyCode::KEY_S.0,
|
|
|
|
|
0,
|
|
|
|
|
)])
|
|
|
|
|
.expect("emit s up");
|
|
|
|
|
thread::sleep(std::time::Duration::from_millis(25));
|
|
|
|
|
agg.process_keyboard_updates();
|
|
|
|
|
|
|
|
|
|
let reports: Vec<Vec<u8>> =
|
|
|
|
|
std::iter::from_fn(|| rx.try_recv().ok().map(|pkt| pkt.data)).collect();
|
|
|
|
|
assert!(
|
|
|
|
|
reports.contains(&vec![0, 0, 0x04, 0, 0, 0, 0, 0]),
|
|
|
|
|
"expected A-down report, got {reports:?}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
reports.iter().any(|pkt| {
|
|
|
|
|
let keys = &pkt[2..8];
|
|
|
|
|
keys.contains(&0x04) && keys.contains(&0x16)
|
|
|
|
|
}),
|
|
|
|
|
"expected merged A+S overlap report, got {reports:?}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
reports.contains(&vec![0, 0, 0x16, 0, 0, 0, 0, 0]),
|
|
|
|
|
"expected lone S report after A release, got {reports:?}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
reports.contains(&vec![0; 8]),
|
|
|
|
|
"expected final empty report after both releases, got {reports:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-16 12:58:05 -03:00
|
|
|
}
|