382 lines
13 KiB
Rust
382 lines
13 KiB
Rust
//! 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]);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn set_send_false_blocks_manual_empty_report() {
|
|
let Some(dev) = open_any_keyboard_device()
|
|
.or_else(|| build_keyboard("lesavka-include-kbd-nosend").map(|(_, dev)| dev))
|
|
else {
|
|
return;
|
|
};
|
|
let (mut agg, mut rx) = new_aggregator(dev);
|
|
agg.set_send(false);
|
|
agg.send_empty_report();
|
|
assert!(rx.try_recv().is_err());
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn process_events_respects_send_toggle() {
|
|
let Some((mut vdev, dev)) = build_keyboard("lesavka-include-kbd-send-toggle") else {
|
|
return;
|
|
};
|
|
let (mut agg, mut rx) = new_aggregator(dev);
|
|
agg.set_send(false);
|
|
vdev.emit(&[evdev::InputEvent::new(
|
|
evdev::EventType::KEY.0,
|
|
evdev::KeyCode::KEY_B.0,
|
|
1,
|
|
)])
|
|
.expect("emit key");
|
|
thread::sleep(std::time::Duration::from_millis(20));
|
|
agg.process_events();
|
|
assert!(rx.try_recv().is_err(), "send-disabled aggregator should not publish reports");
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn paste_chord_active_supports_ctrl_v_variant() {
|
|
let Some(dev) = open_any_keyboard_device()
|
|
.or_else(|| build_keyboard("lesavka-include-kbd-ctrl-v").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_V);
|
|
with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+v"), || {
|
|
assert!(agg.paste_chord_active());
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn paste_debounced_rejects_rapid_repeat_with_positive_window() {
|
|
let Some(dev) = open_any_keyboard_device()
|
|
.or_else(|| build_keyboard("lesavka-include-kbd-debounce").map(|(_, dev)| dev))
|
|
else {
|
|
return;
|
|
};
|
|
let (agg, _) = new_aggregator(dev);
|
|
let now_ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("time")
|
|
.as_millis() as u64;
|
|
LAST_PASTE_MS.store(now_ms, Ordering::Relaxed);
|
|
with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("9999"), || {
|
|
assert!(!agg.paste_debounced());
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn paste_via_rpc_returns_false_without_sender() {
|
|
let Some(dev) = open_any_keyboard_device()
|
|
.or_else(|| build_keyboard("lesavka-include-kbd-rpc-none").map(|(_, dev)| dev))
|
|
else {
|
|
return;
|
|
};
|
|
let (tx, _rx) = tokio::sync::broadcast::channel(8);
|
|
let agg = KeyboardAggregator::new(dev, false, tx, None);
|
|
assert!(!agg.paste_via_rpc());
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn read_clipboard_text_returns_none_when_custom_and_fallback_tools_fail() {
|
|
with_var("LESAVKA_CLIPBOARD_CMD", Some("nonexistent-clipboard-command"), || {
|
|
with_var("PATH", Some("/tmp/definitely-missing-path"), || {
|
|
assert!(read_clipboard_text().is_none());
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn try_handle_paste_event_swallows_incomplete_chord_sequences() {
|
|
let Some(dev) = open_any_keyboard_device()
|
|
.or_else(|| build_keyboard("lesavka-include-kbd-incomplete").map(|(_, dev)| dev))
|
|
else {
|
|
return;
|
|
};
|
|
let (mut agg, mut rx) = new_aggregator(dev);
|
|
agg.paste_enabled = true;
|
|
agg.paste_chord_armed = true;
|
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
|
|
|
assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_LEFTCTRL, 1));
|
|
let pkt = rx.try_recv().expect("swallow report");
|
|
assert_eq!(pkt.data, vec![0; 8]);
|
|
}
|
|
}
|