lesavka/testing/tests/client_keyboard_include_extra_contract.rs

257 lines
8.6 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;
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]
#[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 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 read_clipboard_text_uses_fallback_tool_when_available() {
let wl_paste = "#!/usr/bin/env sh\nprintf 'fallback-clipboard'\n";
with_var("LESAVKA_CLIPBOARD_CMD", None::<&str>, || {
with_fake_path_command("wl-paste", wl_paste, || {
let text = read_clipboard_text().expect("fallback clipboard text");
assert_eq!(text, "fallback-clipboard");
});
});
}
#[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]
#[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]);
}
}