//! Keyboard event-processing coverage for swallowed and live-report paths. //! //! Scope: include keyboard aggregation and drive synthetic evdev updates through //! `process_events`/`drain_key_updates`. //! Targets: `client/src/input/keyboard.rs`. //! Why: paste chords must be swallowed cleanly while normal keys keep flowing. mod keymap { pub use lesavka_client::input::keymap::*; } #[allow(warnings)] mod keyboard_process_contract { include!(env!("LESAVKA_CLIENT_KEYBOARD_SRC")); use evdev::AttributeSet; use evdev::uinput::VirtualDevice; use serial_test::serial; use std::thread; use temp_env::{with_var, with_vars}; fn open_virtual_device(vdev: &mut VirtualDevice) -> Option { 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_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> { let mut keys = AttributeSet::::new(); for key in [ evdev::KeyCode::KEY_A, evdev::KeyCode::KEY_V, evdev::KeyCode::KEY_LEFTCTRL, evdev::KeyCode::KEY_LEFTALT, ] { keys.insert(key); } let mut vdev = VirtualDevice::builder() .ok()? .name(name) .with_keys(&keys) .ok()? .build() .ok()?; let dev = open_virtual_device(&mut vdev)?; Some((vdev, dev)) } #[test] #[cfg(coverage)] #[serial] fn process_events_skips_swallowed_paste_chord_updates() { let Some((mut vdev, dev)) = build_keyboard_pair("lesavka-kbd-process-paste") else { return; }; let (tx, mut rx) = tokio::sync::broadcast::channel(64); let mut agg = KeyboardAggregator::new(dev, true, tx, None); agg.paste_enabled = true; agg.paste_rpc_enabled = false; with_vars( [ ("LESAVKA_CLIPBOARD_CHORD", Some("ctrl+alt+v")), ("LESAVKA_CLIPBOARD_DEBOUNCE_MS", Some("0")), ("LESAVKA_CLIPBOARD_CMD", Some("printf 'a'")), ], || { vdev.emit(&[ evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_LEFTCTRL.0, 1, ), evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_LEFTALT.0, 1, ), evdev::InputEvent::new(evdev::EventType::KEY.0, evdev::KeyCode::KEY_V.0, 1), ]) .expect("emit paste chord"); thread::sleep(std::time::Duration::from_millis(25)); agg.process_events(); }, ); let reports: Vec> = std::iter::from_fn(|| rx.try_recv().ok().map(|pkt| pkt.data)).collect(); assert!( reports.iter().any(|report| report == &vec![0; 8]), "swallowed paste chord should publish empty guard reports" ); assert!( reports .iter() .all(|report| report.get(2).copied() != Some(evdev::KeyCode::KEY_V.0 as u8)), "literal V should not leak after the paste chord is swallowed" ); } #[test] #[cfg(coverage)] #[serial] fn drain_key_updates_covers_dev_mode_logging_and_env_disable() { let Some((mut vdev, dev)) = build_keyboard_pair("lesavka-kbd-process-live") else { return; }; let (tx, _rx) = tokio::sync::broadcast::channel(16); with_var("LESAVKA_CLIPBOARD_PASTE", Some("0"), || { let mut agg = KeyboardAggregator::new(dev, true, tx, None); assert!(!agg.paste_enabled); vdev.emit(&[evdev::InputEvent::new( evdev::EventType::KEY.0, evdev::KeyCode::KEY_A.0, 1, )]) .expect("emit live key"); thread::sleep(std::time::Duration::from_millis(25)); let updates = agg.drain_key_updates(); assert_eq!(updates.len(), 1); assert!(!updates[0].swallowed); }); } }