//! Routing, swap-key, and failsafe coverage for client input aggregation. //! //! Scope: include the input aggregator source and exercise local/remote routing //! behavior, quick-toggle handling, and opt-in remote failsafe behavior. //! Targets: `client/src/input/inputs.rs`. //! Why: swap-key and routing regressions can lock the operator out of local //! control, so these paths need dedicated contract coverage. 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; use temp_env::with_var; 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() -> Option { let mut keys = AttributeSet::::new(); keys.insert(evdev::KeyCode::KEY_A); keys.insert(evdev::KeyCode::KEY_ENTER); let mut vdev = VirtualDevice::builder() .ok()? .name("input-classify-kbd") .with_keys(&keys) .ok()? .build() .ok()?; open_virtual_device(&mut vdev) } fn build_mouse() -> Option { let mut keys = AttributeSet::::new(); keys.insert(evdev::KeyCode::BTN_LEFT); let mut rel = AttributeSet::::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) } fn build_touch_mouse() -> Option { let mut keys = AttributeSet::::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 { let mut keys = AttributeSet::::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) } fn build_named_keyboard(name: &str) -> Option { let mut keys = AttributeSet::::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) } fn build_keyboard_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> { 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)> { let mut keys = AttributeSet::::new(); for key in supported_keys { 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)) } fn build_mouse_pair(name: &str) -> Option<(VirtualDevice, evdev::Device)> { let mut keys = AttributeSet::::new(); keys.insert(evdev::KeyCode::BTN_LEFT); let mut rel = AttributeSet::::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)) } 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) } #[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) ); assert_eq!( parse_quick_toggle_key("sysrq"), Some(evdev::KeyCode::KEY_SYSRQ) ); 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"), Some(evdev::KeyCode::KEY_PAUSE) ); } #[test] #[serial] fn quick_toggle_key_env_defaults_and_respects_explicit_disable() { with_var("LESAVKA_INPUT_TOGGLE_KEY", None::<&str>, || { assert_eq!(quick_toggle_key_from_env(), Some(evdev::KeyCode::KEY_PAUSE)); }); 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>, || { assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(350)); }); 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"), || { assert_eq!(quick_toggle_debounce_from_env(), Duration::from_millis(900)); }); } #[test] #[serial] fn remote_failsafe_timeout_env_is_opt_in_and_allows_disable() { 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) ); }); }); 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); }); }); with_var("LESAVKA_INPUT_REMOTE_FAILSAFE_SECS", Some("60"), || { assert_eq!(remote_failsafe_timeout_from_env(), Duration::from_secs(60)); }); } #[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()); }); } #[test] fn enable_remote_capture_arms_failsafe_and_local_release_clears_it() { let mut agg = new_aggregator(); let remote_capture_enabled = agg.remote_capture_enabled_handle(); agg.released = true; agg.pending_release = false; agg.remote_failsafe_timeout = Duration::from_millis(5_000); agg.enable_remote_capture(); assert!(remote_capture_enabled.load(Ordering::Relaxed)); assert!(agg.remote_capture_active()); assert!( agg.remote_failsafe_started_at.is_some(), "remote capture should arm the temporary failsafe window" ); agg.begin_local_release(); assert!(!agg.remote_capture_active()); assert!( agg.remote_failsafe_started_at.is_none(), "returning control locally should clear the failsafe timer" ); } #[test] fn local_release_timeout_helpers_are_stable() { let mut agg = new_aggregator(); assert!(!agg.pending_release_timed_out()); agg.pending_release = true; agg.pending_release_timeout = Duration::from_millis(1); agg.pending_release_started_at = Some(Instant::now() - Duration::from_millis(5)); assert!(agg.pending_release_timed_out()); } #[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); } #[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" ); } #[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" ); } #[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" ); } }