2026-04-12 19:47:26 -03:00
|
|
|
//! 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;
|
2026-04-14 04:02:39 -03:00
|
|
|
use temp_env::with_var;
|
2026-04-12 19:47:26 -03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_keyboard() -> Option<evdev::Device> {
|
|
|
|
|
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
|
|
|
|
keys.insert(evdev::KeyCode::KEY_A);
|
|
|
|
|
keys.insert(evdev::KeyCode::KEY_ENTER);
|
|
|
|
|
|
|
|
|
|
let mut vdev = VirtualDevice::builder()
|
|
|
|
|
.ok()?
|
2026-04-16 15:07:25 -03:00
|
|
|
.name("input-classify-kbd")
|
2026-04-12 19:47:26 -03:00
|
|
|
.with_keys(&keys)
|
|
|
|
|
.ok()?
|
|
|
|
|
.build()
|
|
|
|
|
.ok()?;
|
|
|
|
|
|
|
|
|
|
open_virtual_device(&mut vdev)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_mouse() -> Option<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("lesavka-input-classify-mouse")
|
|
|
|
|
.with_keys(&keys)
|
|
|
|
|
.ok()?
|
|
|
|
|
.with_relative_axes(&rel)
|
|
|
|
|
.ok()?
|
|
|
|
|
.build()
|
|
|
|
|
.ok()?;
|
|
|
|
|
|
|
|
|
|
open_virtual_device(&mut vdev)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 21:18:17 -03:00
|
|
|
fn build_touch_mouse() -> Option<evdev::Device> {
|
|
|
|
|
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
|
|
|
|
keys.insert(evdev::KeyCode::BTN_TOUCH);
|
|
|
|
|
let abs = evdev::AbsInfo::new(0, 0, 1024, 0, 0, 0);
|
|
|
|
|
|
|
|
|
|
let mut vdev = VirtualDevice::builder()
|
|
|
|
|
.ok()?
|
|
|
|
|
.name("lesavka-input-classify-touch")
|
|
|
|
|
.with_keys(&keys)
|
|
|
|
|
.ok()?
|
|
|
|
|
.with_absolute_axis(&evdev::UinputAbsSetup::new(
|
|
|
|
|
evdev::AbsoluteAxisCode::ABS_MT_POSITION_X,
|
|
|
|
|
abs,
|
|
|
|
|
))
|
|
|
|
|
.ok()?
|
|
|
|
|
.with_absolute_axis(&evdev::UinputAbsSetup::new(
|
|
|
|
|
evdev::AbsoluteAxisCode::ABS_MT_POSITION_Y,
|
|
|
|
|
abs,
|
|
|
|
|
))
|
|
|
|
|
.ok()?
|
|
|
|
|
.build()
|
|
|
|
|
.ok()?;
|
|
|
|
|
|
|
|
|
|
open_virtual_device(&mut vdev)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_misc_key_device() -> Option<evdev::Device> {
|
|
|
|
|
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
|
|
|
|
keys.insert(evdev::KeyCode::KEY_VOLUMEUP);
|
|
|
|
|
|
|
|
|
|
let mut vdev = VirtualDevice::builder()
|
|
|
|
|
.ok()?
|
|
|
|
|
.name("lesavka-input-classify-other")
|
|
|
|
|
.with_keys(&keys)
|
|
|
|
|
.ok()?
|
|
|
|
|
.build()
|
|
|
|
|
.ok()?;
|
|
|
|
|
|
|
|
|
|
open_virtual_device(&mut vdev)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:07:25 -03:00
|
|
|
fn build_named_keyboard(name: &str) -> Option<evdev::Device> {
|
|
|
|
|
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
|
|
|
|
keys.insert(evdev::KeyCode::KEY_A);
|
|
|
|
|
keys.insert(evdev::KeyCode::KEY_ENTER);
|
|
|
|
|
|
|
|
|
|
let mut vdev = VirtualDevice::builder()
|
|
|
|
|
.ok()?
|
|
|
|
|
.name(name)
|
|
|
|
|
.with_keys(&keys)
|
|
|
|
|
.ok()?
|
|
|
|
|
.build()
|
|
|
|
|
.ok()?;
|
|
|
|
|
|
|
|
|
|
open_virtual_device(&mut vdev)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 21:18:17 -03:00
|
|
|
fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> {
|
2026-04-20 22:13:58 -03:00
|
|
|
build_keyboard_pair_with_keys(name, &[evdev::KeyCode::KEY_A, evdev::KeyCode::KEY_ENTER])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_keyboard_pair_with_keys(
|
|
|
|
|
name: &str,
|
|
|
|
|
supported_keys: &[evdev::KeyCode],
|
|
|
|
|
) -> Option<(VirtualDevice, evdev::Device)> {
|
2026-04-12 21:18:17 -03:00
|
|
|
let mut keys = AttributeSet::<evdev::KeyCode>::new();
|
2026-04-20 22:13:58 -03:00
|
|
|
for key in supported_keys {
|
|
|
|
|
keys.insert(*key);
|
|
|
|
|
}
|
2026-04-12 21:18:17 -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-13 02:52:32 -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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 20:08:45 -03:00
|
|
|
fn new_aggregator() -> InputAggregator {
|
|
|
|
|
let (kbd_tx, _) = tokio::sync::broadcast::channel(32);
|
|
|
|
|
let (mou_tx, _) = tokio::sync::broadcast::channel(32);
|
|
|
|
|
InputAggregator::new(false, kbd_tx, mou_tx, None)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 19:47:26 -03:00
|
|
|
#[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));
|
|
|
|
|
}
|
2026-04-12 21:18:17 -03:00
|
|
|
|
|
|
|
|
if let Some(touch) = build_touch_mouse() {
|
|
|
|
|
assert!(matches!(classify_device(&touch), DeviceKind::Mouse));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(other) = build_misc_key_device() {
|
|
|
|
|
assert!(matches!(classify_device(&other), DeviceKind::Other));
|
|
|
|
|
}
|
2026-04-12 19:47:26 -03:00
|
|
|
}
|
2026-04-12 20:08:45 -03:00
|
|
|
|
2026-04-16 15:07:25 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn classify_device_ignores_synthetic_automation_keyboards() {
|
|
|
|
|
if let Some(automation) = build_named_keyboard("Lesavka Automation Input") {
|
|
|
|
|
assert!(matches!(classify_device(&automation), DeviceKind::Other));
|
|
|
|
|
}
|
|
|
|
|
if let Some(persistent) = build_named_keyboard("codex-persistent-kbd") {
|
|
|
|
|
assert!(matches!(classify_device(&persistent), DeviceKind::Other));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 20:08:45 -03:00
|
|
|
#[test]
|
|
|
|
|
fn toggle_grab_switches_into_local_control_mode() {
|
|
|
|
|
let mut agg = new_aggregator();
|
|
|
|
|
agg.toggle_grab();
|
|
|
|
|
assert!(agg.pending_release);
|
|
|
|
|
assert!(!agg.released);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn toggle_grab_switches_back_to_remote_control_when_released() {
|
|
|
|
|
let mut agg = new_aggregator();
|
|
|
|
|
agg.released = true;
|
|
|
|
|
agg.pending_release = false;
|
|
|
|
|
agg.toggle_grab();
|
|
|
|
|
assert!(!agg.released);
|
|
|
|
|
assert!(!agg.pending_release);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn toggle_grab_ignores_requests_while_release_is_pending() {
|
|
|
|
|
let mut agg = new_aggregator();
|
|
|
|
|
agg.pending_release = true;
|
|
|
|
|
agg.toggle_grab();
|
|
|
|
|
assert!(agg.pending_release);
|
|
|
|
|
assert!(!agg.released);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 21:18:17 -03:00
|
|
|
#[test]
|
|
|
|
|
fn toggle_grab_ignores_requests_while_kill_release_is_pending() {
|
|
|
|
|
let mut agg = new_aggregator();
|
|
|
|
|
agg.pending_kill = true;
|
|
|
|
|
agg.toggle_grab();
|
|
|
|
|
assert!(agg.pending_kill);
|
|
|
|
|
assert!(!agg.pending_release);
|
|
|
|
|
assert!(!agg.released);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn capture_pending_keys_collects_current_keyboard_state() {
|
|
|
|
|
let Some((mut vdev, dev)) = build_keyboard_pair("lesavka-input-pending-keys") 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,
|
|
|
|
|
)])
|
|
|
|
|
.expect("emit key press");
|
|
|
|
|
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.keyboards.push(keyboard);
|
|
|
|
|
agg.capture_pending_keys();
|
|
|
|
|
|
|
|
|
|
assert!(agg.pending_keys.contains(&evdev::KeyCode::KEY_A));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 02:52:32 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn init_grabs_virtual_keyboard_and_mouse_when_available() {
|
|
|
|
|
let Some((_kbd_vdev, _kbd_dev)) = build_keyboard_pair("lesavka-input-init-kbd") else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let Some((_mouse_vdev, _mouse_dev)) = build_mouse_pair("lesavka-input-init-mouse") else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut agg = new_aggregator();
|
|
|
|
|
let result = agg.init();
|
2026-04-14 04:02:39 -03:00
|
|
|
assert!(
|
|
|
|
|
result.is_ok(),
|
|
|
|
|
"init should succeed with virtual input devices"
|
|
|
|
|
);
|
2026-04-13 02:52:32 -03:00
|
|
|
assert!(
|
|
|
|
|
!agg.keyboards.is_empty() || !agg.mice.is_empty(),
|
|
|
|
|
"init should discover at least one virtual input device"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 20:08:45 -03:00
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
|
|
|
async fn run_returns_once_pending_kill_chord_is_released() {
|
|
|
|
|
let mut agg = new_aggregator();
|
|
|
|
|
agg.pending_kill = true;
|
|
|
|
|
|
|
|
|
|
let result = tokio::time::timeout(Duration::from_millis(200), agg.run()).await;
|
2026-04-14 04:02:39 -03:00
|
|
|
assert!(
|
|
|
|
|
result.is_ok(),
|
|
|
|
|
"run should resolve instead of looping forever"
|
|
|
|
|
);
|
2026-04-12 20:08:45 -03:00
|
|
|
assert!(result.expect("timeout result").is_ok());
|
|
|
|
|
assert!(agg.released);
|
|
|
|
|
}
|
2026-04-13 02:52:32 -03:00
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
|
|
|
async fn run_releases_pending_kill_when_captured_keys_are_not_pressed() {
|
|
|
|
|
let mut agg = new_aggregator();
|
|
|
|
|
agg.pending_kill = true;
|
|
|
|
|
agg.pending_keys.insert(evdev::KeyCode::KEY_A);
|
|
|
|
|
|
|
|
|
|
let result = tokio::time::timeout(Duration::from_millis(200), agg.run()).await;
|
2026-04-14 04:02:39 -03:00
|
|
|
assert!(
|
|
|
|
|
result.is_ok(),
|
|
|
|
|
"run should resolve when pending keys are released"
|
|
|
|
|
);
|
2026-04-13 02:52:32 -03:00
|
|
|
assert!(result.expect("timeout result").is_ok());
|
|
|
|
|
assert!(agg.released);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn toggle_grab_updates_attached_keyboard_and_mouse_modes() {
|
|
|
|
|
let Some((_kbd_vdev, kbd_dev)) = build_keyboard_pair("lesavka-input-toggle-kbd") else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let Some((_mouse_vdev, mouse_dev)) = build_mouse_pair("lesavka-input-toggle-mouse") else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (kbd_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
|
|
|
|
|
let keyboard = KeyboardAggregator::new(kbd_dev, false, kbd_tx.clone(), None);
|
|
|
|
|
let mouse = MouseAggregator::new(mouse_dev, false, mou_tx.clone());
|
|
|
|
|
|
|
|
|
|
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
|
|
|
|
|
agg.keyboards.push(keyboard);
|
|
|
|
|
agg.mice.push(mouse);
|
|
|
|
|
|
|
|
|
|
agg.toggle_grab();
|
2026-04-14 04:02:39 -03:00
|
|
|
assert!(
|
|
|
|
|
agg.pending_release,
|
|
|
|
|
"toggle should enter pending-release mode"
|
|
|
|
|
);
|
2026-04-13 02:52:32 -03:00
|
|
|
assert!(!agg.released);
|
|
|
|
|
|
|
|
|
|
agg.released = true;
|
|
|
|
|
agg.pending_release = false;
|
|
|
|
|
agg.toggle_grab();
|
2026-04-14 04:02:39 -03:00
|
|
|
assert!(
|
|
|
|
|
!agg.pending_release,
|
|
|
|
|
"remote-control toggle clears pending-release"
|
|
|
|
|
);
|
2026-04-13 02:52:32 -03:00
|
|
|
assert!(!agg.released, "remote-control toggle restores grabbed mode");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
|
|
|
#[serial]
|
|
|
|
|
async fn run_pending_release_branch_resets_attached_devices() {
|
2026-04-14 04:02:39 -03:00
|
|
|
let Some((_kbd_vdev, kbd_dev)) = build_keyboard_pair("lesavka-input-run-release-kbd")
|
|
|
|
|
else {
|
2026-04-13 02:52:32 -03:00
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let Some((_mouse_vdev, mouse_dev)) = build_mouse_pair("lesavka-input-run-release-mouse")
|
|
|
|
|
else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (kbd_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let (mou_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
let keyboard = KeyboardAggregator::new(kbd_dev, false, kbd_tx.clone(), None);
|
|
|
|
|
let mouse = MouseAggregator::new(mouse_dev, false, mou_tx.clone());
|
|
|
|
|
|
|
|
|
|
let mut agg = InputAggregator::new(false, kbd_tx, mou_tx, None);
|
|
|
|
|
agg.keyboards.push(keyboard);
|
|
|
|
|
agg.mice.push(mouse);
|
|
|
|
|
agg.pending_release = true;
|
|
|
|
|
|
|
|
|
|
let result = tokio::time::timeout(Duration::from_millis(120), agg.run()).await;
|
2026-04-14 04:02:39 -03:00
|
|
|
assert!(
|
|
|
|
|
result.is_err(),
|
|
|
|
|
"run should continue looping after release handling"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
agg.released,
|
|
|
|
|
"pending-release flow should mark local control as released"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!agg.pending_release,
|
|
|
|
|
"pending-release flow should clear pending flag"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn quick_toggle_key_parser_handles_supported_aliases_and_disable_switch() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_quick_toggle_key("scrolllock"),
|
|
|
|
|
Some(evdev::KeyCode::KEY_SCROLLLOCK)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_quick_toggle_key("pause"),
|
|
|
|
|
Some(evdev::KeyCode::KEY_PAUSE)
|
|
|
|
|
);
|
2026-04-14 13:09:25 -03:00
|
|
|
assert_eq!(
|
|
|
|
|
parse_quick_toggle_key("sysrq"),
|
|
|
|
|
Some(evdev::KeyCode::KEY_SYSRQ)
|
|
|
|
|
);
|
2026-04-14 04:02:39 -03:00
|
|
|
assert_eq!(parse_quick_toggle_key("f12"), Some(evdev::KeyCode::KEY_F12));
|
|
|
|
|
assert_eq!(parse_quick_toggle_key("off"), None);
|
|
|
|
|
assert_eq!(parse_quick_toggle_key("none"), None);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_quick_toggle_key("definitely-unknown"),
|
2026-04-14 13:09:25 -03:00
|
|
|
Some(evdev::KeyCode::KEY_PAUSE)
|
2026-04-14 04:02:39 -03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn quick_toggle_key_env_defaults_and_respects_explicit_disable() {
|
|
|
|
|
with_var("LESAVKA_INPUT_TOGGLE_KEY", None::<&str>, || {
|
2026-04-14 13:09:25 -03:00
|
|
|
assert_eq!(quick_toggle_key_from_env(), Some(evdev::KeyCode::KEY_PAUSE));
|
2026-04-14 04:02:39 -03:00
|
|
|
});
|
|
|
|
|
with_var("LESAVKA_INPUT_TOGGLE_KEY", Some("off"), || {
|
|
|
|
|
assert_eq!(quick_toggle_key_from_env(), None);
|
|
|
|
|
});
|
|
|
|
|
with_var("LESAVKA_INPUT_TOGGLE_KEY", Some("f11"), || {
|
|
|
|
|
assert_eq!(quick_toggle_key_from_env(), Some(evdev::KeyCode::KEY_F11));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn quick_toggle_debounce_env_uses_defaults_and_applies_safety_floor() {
|
|
|
|
|
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", None::<&str>, || {
|
2026-04-14 13:09:25 -03:00
|
|
|
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(350));
|
2026-04-14 04:02:39 -03:00
|
|
|
});
|
|
|
|
|
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("20"), || {
|
|
|
|
|
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(50));
|
|
|
|
|
});
|
|
|
|
|
with_var("LESAVKA_INPUT_TOGGLE_DEBOUNCE_MS", Some("900"), || {
|
2026-04-14 13:09:25 -03:00
|
|
|
assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(900));
|
2026-04-14 04:02:39 -03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 21:11:33 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
2026-04-21 17:13:31 -03:00
|
|
|
fn remote_failsafe_timeout_env_is_opt_in_and_allows_disable() {
|
2026-04-21 17:55:26 -03:00
|
|
|
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", None::<&str>, || {
|
|
|
|
|
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", None::<&str>, || {
|
|
|
|
|
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
|
|
|
|
|
});
|
|
|
|
|
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("0"), || {
|
|
|
|
|
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_millis(0));
|
|
|
|
|
});
|
|
|
|
|
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
remote_failsafe_timeout_from_env(),
|
|
|
|
|
Duration::from_millis(60_000)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("1500"), || {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
remote_failsafe_timeout_from_env(),
|
|
|
|
|
Duration::from_millis(1_500)
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-04-21 17:13:31 -03:00
|
|
|
});
|
2026-04-21 17:55:26 -03:00
|
|
|
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("0"), || {
|
|
|
|
|
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_MS", Some("60000"), || {
|
|
|
|
|
assert_eq!(remote_failsafe_timeout_from_env(), Duration::ZERO);
|
|
|
|
|
});
|
2026-04-21 17:13:31 -03:00
|
|
|
});
|
2026-04-21 17:55:26 -03:00
|
|
|
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
|
|
|
|
|
assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_secs(60));
|
2026-04-20 21:11:33 -03:00
|
|
|
});
|
2026-04-21 17:55:26 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn boot_remote_capture_only_arms_failsafe_when_launch_option_is_nonzero() {
|
|
|
|
|
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("0"), || {
|
|
|
|
|
let agg = new_aggregator();
|
|
|
|
|
assert_eq!(agg.remote_failsafe_timeout, Duration::ZERO);
|
|
|
|
|
assert!(agg.remote_failsafe_started_at.is_none());
|
|
|
|
|
});
|
|
|
|
|
with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || {
|
|
|
|
|
let agg = new_aggregator();
|
|
|
|
|
assert_eq!(agg.remote_failsafe_timeout, Duration::from_secs(60));
|
|
|
|
|
assert!(agg.remote_failsafe_started_at.is_some());
|
2026-04-20 21:11:33 -03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn enable_remote_capture_arms_failsafe_and_local_release_clears_it() {
|
|
|
|
|
let mut agg = new_aggregator();
|
|
|
|
|
agg.released = true;
|
|
|
|
|
agg.pending_release = false;
|
|
|
|
|
agg.remote_failsafe_timeout = Duration::from_millis(5_000);
|
|
|
|
|
|
|
|
|
|
agg.enable_remote_capture();
|
|
|
|
|
assert!(
|
|
|
|
|
agg.remote_failsafe_started_at.is_some(),
|
|
|
|
|
"remote capture should arm the temporary failsafe window"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
agg.begin_local_release();
|
|
|
|
|
assert!(
|
|
|
|
|
agg.remote_failsafe_started_at.is_none(),
|
|
|
|
|
"returning control locally should clear the failsafe timer"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 17:13:31 -03:00
|
|
|
#[test]
|
|
|
|
|
fn enable_remote_capture_does_not_auto_cutoff_when_failsafe_is_disabled() {
|
|
|
|
|
let mut agg = new_aggregator();
|
|
|
|
|
agg.released = true;
|
|
|
|
|
agg.pending_release = false;
|
|
|
|
|
agg.remote_failsafe_timeout = Duration::ZERO;
|
|
|
|
|
|
|
|
|
|
agg.enable_remote_capture();
|
|
|
|
|
assert!(
|
|
|
|
|
agg.remote_failsafe_started_at.is_none(),
|
|
|
|
|
"normal remote input sessions should not silently flip back to local"
|
|
|
|
|
);
|
|
|
|
|
assert!(agg.remote_capture_enabled.load(Ordering::Relaxed));
|
|
|
|
|
assert!(!agg.released);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 21:11:33 -03:00
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
|
|
|
async fn run_remote_failsafe_returns_control_to_local_machine() {
|
|
|
|
|
let mut agg = new_aggregator();
|
|
|
|
|
agg.remote_failsafe_timeout = Duration::from_millis(1);
|
|
|
|
|
agg.remote_failsafe_started_at =
|
|
|
|
|
Some(std::time::Instant::now() - Duration::from_millis(10));
|
|
|
|
|
|
|
|
|
|
let result = tokio::time::timeout(Duration::from_millis(120), agg.run()).await;
|
|
|
|
|
assert!(
|
|
|
|
|
result.is_err(),
|
|
|
|
|
"run should keep looping after the failsafe returns control locally"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
agg.released,
|
|
|
|
|
"failsafe expiry should release devices back to the local machine"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!agg.pending_release,
|
|
|
|
|
"failsafe expiry should complete the local-release handoff"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
agg.remote_failsafe_started_at.is_none(),
|
|
|
|
|
"failsafe timer should clear once local control is restored"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 22:13:58 -03:00
|
|
|
#[test]
|
|
|
|
|
#[serial]
|
|
|
|
|
fn quick_toggle_tap_flips_routing_when_processed_through_input_aggregator() {
|
|
|
|
|
let Some((mut vdev, dev)) = build_keyboard_pair_with_keys(
|
|
|
|
|
"lesavka-input-toggle-pause",
|
|
|
|
|
&[
|
|
|
|
|
evdev::KeyCode::KEY_A,
|
|
|
|
|
evdev::KeyCode::KEY_ENTER,
|
|
|
|
|
evdev::KeyCode::KEY_PAUSE,
|
|
|
|
|
],
|
|
|
|
|
) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (kbd_tx, _) = tokio::sync::broadcast::channel(16);
|
|
|
|
|
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.quick_toggle_key = Some(evdev::KeyCode::KEY_PAUSE);
|
|
|
|
|
agg.quick_toggle_debounce = Duration::from_millis(0);
|
|
|
|
|
agg.keyboards.push(keyboard);
|
|
|
|
|
|
|
|
|
|
vdev.emit(&[
|
|
|
|
|
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_PAUSE.0, 1),
|
|
|
|
|
evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_PAUSE.0, 0),
|
|
|
|
|
])
|
|
|
|
|
.expect("emit pause tap");
|
|
|
|
|
thread::sleep(std::time::Duration::from_millis(20));
|
|
|
|
|
|
|
|
|
|
agg.process_keyboard_updates();
|
|
|
|
|
let quick_toggle_now = agg.quick_toggle_active();
|
|
|
|
|
agg.observe_quick_toggle(quick_toggle_now);
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
agg.pending_release,
|
|
|
|
|
"a quick swap-key tap should start the local handoff path"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!agg.released,
|
|
|
|
|
"the relay should still be in pending-release until the local handoff completes"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 04:02:39 -03:00
|
|
|
#[test]
|
|
|
|
|
fn observe_quick_toggle_uses_rising_edge_to_avoid_repeat_toggling() {
|
|
|
|
|
let mut agg = new_aggregator();
|
|
|
|
|
agg.quick_toggle_debounce = Duration::from_millis(0);
|
|
|
|
|
|
|
|
|
|
agg.observe_quick_toggle(true);
|
|
|
|
|
assert!(
|
|
|
|
|
agg.pending_release,
|
|
|
|
|
"first quick-toggle should switch from remote to local pending-release mode"
|
|
|
|
|
);
|
|
|
|
|
assert!(!agg.released);
|
|
|
|
|
|
|
|
|
|
agg.observe_quick_toggle(true);
|
|
|
|
|
assert!(
|
|
|
|
|
agg.pending_release,
|
|
|
|
|
"holding the quick-toggle key should not retrigger mode switching"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
agg.released = true;
|
|
|
|
|
agg.pending_release = false;
|
|
|
|
|
agg.observe_quick_toggle(false);
|
|
|
|
|
agg.observe_quick_toggle(true);
|
|
|
|
|
assert!(
|
|
|
|
|
!agg.released,
|
|
|
|
|
"second rising edge should return to remote mode"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!agg.pending_release,
|
|
|
|
|
"remote-mode transition should clear pending release state"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn observe_quick_toggle_honors_debounce_window() {
|
|
|
|
|
let mut agg = new_aggregator();
|
|
|
|
|
agg.quick_toggle_debounce = Duration::from_secs(60);
|
|
|
|
|
|
|
|
|
|
agg.released = true;
|
|
|
|
|
agg.pending_release = false;
|
|
|
|
|
agg.observe_quick_toggle(true);
|
|
|
|
|
assert!(!agg.released, "first edge should switch to remote");
|
|
|
|
|
|
|
|
|
|
agg.released = true;
|
|
|
|
|
agg.pending_release = false;
|
|
|
|
|
agg.observe_quick_toggle(false);
|
|
|
|
|
agg.observe_quick_toggle(true);
|
|
|
|
|
assert!(
|
|
|
|
|
agg.released,
|
|
|
|
|
"second edge inside debounce window should be ignored"
|
|
|
|
|
);
|
2026-04-13 02:52:32 -03:00
|
|
|
}
|
2026-04-12 19:47:26 -03:00
|
|
|
}
|