479 lines
16 KiB
Rust
479 lines
16 KiB
Rust
//! Extra integration coverage for client keyboard aggregator helpers.
|
|
//!
|
|
//! Scope: include keyboard input source and cover reset-state, clipboard
|
|
//! fallback, send-toggle, and auxiliary paste branches without blowing the
|
|
//! primary keyboard contract past the 500 LOC cap.
|
|
//! Targets: `client/src/input/keyboard.rs`.
|
|
//! Why: keyboard helper branches are numerous enough to merit a paired
|
|
//! contract file for hygiene-gate modularity.
|
|
|
|
mod keymap {
|
|
pub use lesavka_client::input::keymap::*;
|
|
}
|
|
|
|
#[allow(warnings)]
|
|
mod keyboard_contract_extra {
|
|
include!(env!("LESAVKA_CLIENT_KEYBOARD_SRC"));
|
|
|
|
use serial_test::serial;
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::Path;
|
|
use temp_env::{with_var, with_vars};
|
|
use tempfile::tempdir;
|
|
|
|
fn write_executable(dir: &Path, name: &str, body: &str) {
|
|
let path = dir.join(name);
|
|
fs::write(&path, body).expect("write script");
|
|
let mut perms = fs::metadata(&path).expect("metadata").permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(path, perms).expect("chmod");
|
|
}
|
|
|
|
fn with_fake_path_command(name: &str, script_body: &str, f: impl FnOnce()) {
|
|
let dir = tempdir().expect("tempdir");
|
|
write_executable(dir.path(), name, script_body);
|
|
let prior = std::env::var("PATH").unwrap_or_default();
|
|
let merged = if prior.is_empty() {
|
|
dir.path().display().to_string()
|
|
} else {
|
|
format!("{}:{prior}", dir.path().display())
|
|
};
|
|
with_var("PATH", Some(merged), f);
|
|
}
|
|
|
|
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::Device> {
|
|
let mut keys = evdev::AttributeSet::<evdev::KeyCode>::new();
|
|
for key in [
|
|
evdev::KeyCode::KEY_A,
|
|
evdev::KeyCode::KEY_B,
|
|
evdev::KeyCode::KEY_V,
|
|
evdev::KeyCode::KEY_LEFTCTRL,
|
|
evdev::KeyCode::KEY_LEFTALT,
|
|
] {
|
|
keys.insert(key);
|
|
}
|
|
|
|
let mut vdev = evdev::uinput::VirtualDevice::builder()
|
|
.ok()?
|
|
.name(name)
|
|
.with_keys(&keys)
|
|
.ok()?
|
|
.build()
|
|
.ok()?;
|
|
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);
|
|
}
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
}
|
|
None
|
|
}
|
|
|
|
fn new_aggregator(
|
|
dev: evdev::Device,
|
|
) -> (
|
|
KeyboardAggregator,
|
|
tokio::sync::broadcast::Receiver<KeyboardReport>,
|
|
) {
|
|
let (tx, rx) = tokio::sync::broadcast::channel(64);
|
|
(KeyboardAggregator::new(dev, true, tx, None), rx)
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn reset_state_when_idle_still_emits_an_empty_report() {
|
|
let Some(dev) =
|
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-reset-idle"))
|
|
else {
|
|
return;
|
|
};
|
|
let (mut agg, mut rx) = new_aggregator(dev);
|
|
agg.reset_state();
|
|
let pkt = rx
|
|
.try_recv()
|
|
.expect("idle reset should still publish empty report");
|
|
assert_eq!(pkt.data, vec![0; 8]);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn pressed_keys_snapshot_returns_the_current_keyset() {
|
|
let Some(dev) =
|
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-snapshot"))
|
|
else {
|
|
return;
|
|
};
|
|
let (mut agg, _) = new_aggregator(dev);
|
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_A);
|
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
|
let snapshot = agg.pressed_keys_snapshot();
|
|
assert!(snapshot.contains(&evdev::KeyCode::KEY_A));
|
|
assert!(snapshot.contains(&evdev::KeyCode::KEY_LEFTCTRL));
|
|
assert_eq!(snapshot.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn update_pressed_keys_tracks_press_and_release_values() {
|
|
let mut pressed = HashSet::new();
|
|
update_pressed_keys(&mut pressed, evdev::KeyCode::KEY_A, 1);
|
|
assert!(pressed.contains(&evdev::KeyCode::KEY_A));
|
|
|
|
update_pressed_keys(&mut pressed, evdev::KeyCode::KEY_A, 0);
|
|
assert!(!pressed.contains(&evdev::KeyCode::KEY_A));
|
|
|
|
update_pressed_keys(&mut pressed, evdev::KeyCode::KEY_B, 2);
|
|
assert!(pressed.contains(&evdev::KeyCode::KEY_B));
|
|
}
|
|
|
|
#[test]
|
|
fn debounce_gate_honors_zero_and_nonzero_windows() {
|
|
let last = AtomicU64::new(123);
|
|
assert!(debounce_gate(&last, 500, 0));
|
|
assert_eq!(last.load(Ordering::Relaxed), 500);
|
|
|
|
last.store(9_900, Ordering::Relaxed);
|
|
assert!(!debounce_gate(&last, 10_000, 500));
|
|
assert_eq!(last.load(Ordering::Relaxed), 10_000);
|
|
|
|
assert!(debounce_gate(&last, 10_700, 500));
|
|
assert_eq!(last.load(Ordering::Relaxed), 10_700);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn paste_rpc_enabled_from_env_honors_flag_and_key_variants() {
|
|
let home = tempdir().expect("tempdir");
|
|
temp_env::with_vars(
|
|
[
|
|
("HOME", home.path().to_str()),
|
|
("LESAVKA_PASTE_KEY_FILE", None::<&str>),
|
|
("LESAVKA_PASTE_RPC", Some("0")),
|
|
("LESAVKA_PASTE_KEY", Some("shared-key")),
|
|
],
|
|
|| {
|
|
assert!(!paste_rpc_enabled_from_env());
|
|
},
|
|
);
|
|
|
|
temp_env::with_vars(
|
|
[
|
|
("HOME", home.path().to_str()),
|
|
("LESAVKA_PASTE_KEY_FILE", None::<&str>),
|
|
("LESAVKA_PASTE_RPC", Some("1")),
|
|
("LESAVKA_PASTE_KEY", Some(" ")),
|
|
],
|
|
|| {
|
|
assert!(!paste_rpc_enabled_from_env());
|
|
},
|
|
);
|
|
|
|
temp_env::with_vars(
|
|
[
|
|
("HOME", home.path().to_str()),
|
|
("LESAVKA_PASTE_KEY_FILE", None::<&str>),
|
|
("LESAVKA_PASTE_RPC", Some("1")),
|
|
("LESAVKA_PASTE_KEY", Some("shared-key")),
|
|
],
|
|
|| {
|
|
assert!(paste_rpc_enabled_from_env());
|
|
},
|
|
);
|
|
}
|
|
|
|
#[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"))
|
|
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 paste_chord_active_supports_ctrl_v_variant() {
|
|
let Some(dev) =
|
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-ctrl-v"))
|
|
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 ctrl_v_reaches_remote_when_lesavka_paste_chord_requires_alt() {
|
|
let Some(dev) =
|
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-ctrl-v-app"))
|
|
else {
|
|
return;
|
|
};
|
|
let (mut agg, _) = new_aggregator(dev);
|
|
agg.paste_enabled = true;
|
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_V);
|
|
|
|
with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || {
|
|
assert!(
|
|
!agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1),
|
|
"plain Ctrl+V should relay as an app shortcut, not trigger Lesavka paste"
|
|
);
|
|
});
|
|
assert!(!agg.paste_chord_armed);
|
|
assert!(!agg.paste_chord_consumed);
|
|
}
|
|
|
|
#[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"))
|
|
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 paste_via_rpc_returns_true_for_empty_clipboard_payload() {
|
|
let Some(dev) =
|
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-rpc-empty"))
|
|
else {
|
|
return;
|
|
};
|
|
let (paste_tx, mut paste_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
|
let (kbd_tx, _rx) = tokio::sync::broadcast::channel(8);
|
|
let agg = KeyboardAggregator::new(dev, false, kbd_tx, Some(paste_tx));
|
|
let wl_paste_empty = "#!/usr/bin/env sh\nexit 0\n";
|
|
with_var("LESAVKA_CLIPBOARD_CMD", None::<&str>, || {
|
|
with_fake_path_command("wl-paste", wl_paste_empty, || {
|
|
assert!(
|
|
agg.paste_via_rpc(),
|
|
"empty clipboard should still consume the chord"
|
|
);
|
|
assert!(
|
|
paste_rx.try_recv().is_err(),
|
|
"empty clipboard should not enqueue payload"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(coverage)]
|
|
#[serial]
|
|
fn paste_clipboard_skips_unsupported_chars_in_coverage() {
|
|
let Some(dev) = open_any_keyboard_device()
|
|
.or_else(|| build_keyboard("lesavka-include-kbd-coverage-unsupported"))
|
|
else {
|
|
return;
|
|
};
|
|
let (mut agg, mut rx) = new_aggregator(dev);
|
|
agg.paste_enabled = true;
|
|
|
|
with_var("LESAVKA_CLIPBOARD_CMD", Some("printf '🚀'"), || {
|
|
agg.paste_clipboard();
|
|
});
|
|
|
|
assert!(
|
|
rx.try_recv().is_err(),
|
|
"unsupported clipboard characters should not emit HID reports"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn set_grab_path_is_non_panicking() {
|
|
let Some(dev) =
|
|
open_any_keyboard_device().or_else(|| build_keyboard("lesavka-include-kbd-grab"))
|
|
else {
|
|
return;
|
|
};
|
|
let (mut agg, _) = new_aggregator(dev);
|
|
agg.set_grab(false);
|
|
agg.set_grab(true);
|
|
}
|
|
|
|
#[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"))
|
|
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]);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(coverage)]
|
|
#[serial]
|
|
fn try_handle_paste_event_coverage_path_runs_hid_paste_and_empty_report() {
|
|
let Some(dev) = open_any_keyboard_device()
|
|
.or_else(|| build_keyboard("lesavka-include-kbd-coverage-paste"))
|
|
else {
|
|
return;
|
|
};
|
|
let (tx, mut rx) = tokio::sync::broadcast::channel(32);
|
|
let mut agg = KeyboardAggregator::new(dev, false, tx, None);
|
|
agg.paste_enabled = true;
|
|
agg.paste_rpc_enabled = false;
|
|
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 'a'"), || {
|
|
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 mut saw_hid_payload = false;
|
|
let mut saw_empty = false;
|
|
while let Ok(pkt) = rx.try_recv() {
|
|
if pkt.data == vec![0; 8] {
|
|
saw_empty = true;
|
|
}
|
|
if pkt.data.len() == 8 && pkt.data.iter().any(|byte| *byte != 0) {
|
|
saw_hid_payload = true;
|
|
}
|
|
}
|
|
assert!(
|
|
saw_hid_payload,
|
|
"coverage paste path should emit HID reports"
|
|
);
|
|
assert!(
|
|
saw_empty,
|
|
"coverage paste path should end with an empty report"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(coverage)]
|
|
#[serial]
|
|
fn try_handle_paste_event_coverage_path_respects_debounce_fallthrough() {
|
|
let Some(dev) = open_any_keyboard_device()
|
|
.or_else(|| build_keyboard("lesavka-include-kbd-coverage-debounce"))
|
|
else {
|
|
return;
|
|
};
|
|
let (tx, mut rx) = tokio::sync::broadcast::channel(32);
|
|
let mut agg = KeyboardAggregator::new(dev, false, tx, None);
|
|
agg.paste_enabled = true;
|
|
agg.paste_rpc_enabled = false;
|
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTCTRL);
|
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_LEFTALT);
|
|
agg.pressed_keys.insert(evdev::KeyCode::KEY_V);
|
|
|
|
let now_ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as u64;
|
|
LAST_PASTE_MS.store(now_ms, Ordering::Relaxed);
|
|
|
|
with_var("LESAVKA_CLIPBOARD_CMD", Some("printf 'a'"), || {
|
|
with_var("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v"), || {
|
|
with_var("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("999999"), || {
|
|
assert!(agg.try_handle_paste_event(evdev::KeyCode::KEY_V, 1));
|
|
});
|
|
});
|
|
});
|
|
|
|
let pkt = rx
|
|
.try_recv()
|
|
.expect("debounced paste should still emit a swallowed empty report");
|
|
assert_eq!(pkt.data, vec![0; 8]);
|
|
assert!(
|
|
rx.try_recv().is_err(),
|
|
"debounced paste should not emit HID reports"
|
|
);
|
|
LAST_PASTE_MS.store(0, Ordering::Relaxed);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(coverage)]
|
|
#[serial]
|
|
fn try_handle_paste_event_coverage_path_invokes_rpc_when_enabled() {
|
|
let Some(dev) = open_any_keyboard_device()
|
|
.or_else(|| build_keyboard("lesavka-include-kbd-coverage-rpc"))
|
|
else {
|
|
return;
|
|
};
|
|
let (paste_tx, mut paste_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
|
let (tx, _rx) = tokio::sync::broadcast::channel(32);
|
|
let mut agg = KeyboardAggregator::new(dev, false, 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 'rpc-coverage'"),
|
|
|| {
|
|
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 = paste_rx.try_recv().expect("rpc payload");
|
|
assert_eq!(payload, "rpc-coverage");
|
|
}
|
|
}
|