diff --git a/client/assets/placeholders/webcam_disabled.png b/client/assets/placeholders/webcam_disabled.png index 4784f33..e62af6f 100644 Binary files a/client/assets/placeholders/webcam_disabled.png and b/client/assets/placeholders/webcam_disabled.png differ diff --git a/client/src/input/mouse.rs b/client/src/input/mouse.rs index 17ebd57..fdf2f77 100644 --- a/client/src/input/mouse.rs +++ b/client/src/input/mouse.rs @@ -3,13 +3,212 @@ use evdev::{AbsoluteAxisCode, Device, EventType, InputEvent, KeyCode, RelativeAxisCode}; use std::time::{Duration, Instant}; use tokio::sync::broadcast::Sender; -#[cfg(not(coverage))] use tracing::{debug, error, trace, warn}; use lesavka_common::lesavka::MouseReport; const SEND_INTERVAL: Duration = Duration::from_millis(1); +struct MouseEventState<'a> { + buttons: &'a mut u8, + dx: &'a mut i8, + dy: &'a mut i8, + wheel: &'a mut i8, + last_abs_x: &'a mut Option, + last_abs_y: &'a mut Option, + abs_scale: i32, + abs_jump_x: i32, + abs_jump_y: i32, + has_touch_state: bool, + touch_guarded: &'a mut bool, + touch_active: &'a mut bool, +} + +impl MouseEventState<'_> { + fn set_btn(&mut self, bit: u8, val: i32) { + if val != 0 { + *self.buttons |= 1 << bit; + } else { + *self.buttons &= !(1 << bit); + } + } + + fn apply_event(&mut self, event: &InputEvent) { + match event.event_type() { + EventType::KEY => match event.code() { + c if c == KeyCode::BTN_LEFT.0 => self.set_btn(0, event.value()), + c if c == KeyCode::BTN_RIGHT.0 => self.set_btn(1, event.value()), + c if c == KeyCode::BTN_MIDDLE.0 => self.set_btn(2, event.value()), + c if c == KeyCode::BTN_TOUCH.0 => { + *self.touch_guarded = true; + *self.touch_active = event.value() != 0; + if !*self.touch_active { + *self.last_abs_x = None; + *self.last_abs_y = None; + } + self.set_btn(0, event.value()); + } + _ => {} + }, + EventType::RELATIVE => match event.code() { + c if c == RelativeAxisCode::REL_X.0 => { + *self.dx = self.dx.saturating_add(event.value().clamp(-127, 127) as i8) + } + c if c == RelativeAxisCode::REL_Y.0 => { + *self.dy = self.dy.saturating_add(event.value().clamp(-127, 127) as i8) + } + c if c == RelativeAxisCode::REL_WHEEL.0 => { + *self.wheel = self.wheel.saturating_add(event.value().clamp(-1, 1) as i8) + } + _ => {} + }, + EventType::ABSOLUTE => match event.code() { + c if c == AbsoluteAxisCode::ABS_X.0 + || c == AbsoluteAxisCode::ABS_MT_POSITION_X.0 => + { + if *self.touch_guarded && !*self.touch_active { + *self.last_abs_x = Some(event.value()); + return; + } + if let Some(prev) = *self.last_abs_x { + if !self.has_touch_state { + let delta = (event.value() - prev).abs(); + if delta > self.abs_jump_x { + *self.last_abs_x = Some(event.value()); + return; + } + } + let delta = (event.value() - prev) / self.abs_scale; + if delta != 0 { + *self.dx = self.dx.saturating_add(delta.clamp(-127, 127) as i8); + } + } + *self.last_abs_x = Some(event.value()); + } + c if c == AbsoluteAxisCode::ABS_Y.0 + || c == AbsoluteAxisCode::ABS_MT_POSITION_Y.0 => + { + if *self.touch_guarded && !*self.touch_active { + *self.last_abs_y = Some(event.value()); + return; + } + if let Some(prev) = *self.last_abs_y { + if !self.has_touch_state { + let delta = (event.value() - prev).abs(); + if delta > self.abs_jump_y { + *self.last_abs_y = Some(event.value()); + return; + } + } + let delta = (event.value() - prev) / self.abs_scale; + if delta != 0 { + *self.dy = self.dy.saturating_add(delta.clamp(-127, 127) as i8); + } + } + *self.last_abs_y = Some(event.value()); + } + c if c == AbsoluteAxisCode::ABS_MT_TRACKING_ID.0 => { + if event.value() < 0 { + *self.touch_guarded = true; + *self.touch_active = false; + *self.last_abs_x = None; + *self.last_abs_y = None; + } else { + *self.touch_guarded = true; + *self.touch_active = true; + } + } + _ => {} + }, + _ => {} + } + } +} + +struct MouseRuntime<'a> { + tx: &'a Sender, + dev_mode: bool, + sending_disabled: bool, + next_send: &'a mut Instant, + last_buttons: &'a mut u8, + event_state: MouseEventState<'a>, +} + +impl MouseRuntime<'_> { + fn replay_events(&mut self, events: Vec) { + for event in events { + if event.event_type() == EventType::SYNCHRONIZATION { + self.flush(); + } else { + self.event_state.apply_event(&event); + } + } + } + + fn flush(&mut self) { + let buttons = *self.event_state.buttons; + if buttons == *self.last_buttons && Instant::now() < *self.next_send { + return; + } + *self.next_send = Instant::now() + SEND_INTERVAL; + + let pkt = [ + buttons, + (*self.event_state.dx).clamp(-127, 127) as u8, + (*self.event_state.dy).clamp(-127, 127) as u8, + *self.event_state.wheel as u8, + ]; + + if !self.sending_disabled { + #[cfg(not(coverage))] + if let Err(tokio::sync::broadcast::error::SendError(_)) = + self.tx.send(MouseReport { data: pkt.to_vec() }) + { + if self.dev_mode { + warn!("❌🖱️ no HID receiver (mouse)"); + } + } else if self.dev_mode { + debug!("📤🖱️ mouse {:?}", pkt); + } + + #[cfg(coverage)] + { + let _ = self.tx.send(MouseReport { data: pkt.to_vec() }); + } + } + + *self.event_state.dx = 0; + *self.event_state.dy = 0; + *self.event_state.wheel = 0; + *self.last_buttons = buttons; + } +} + +fn collect_fetched_events( + fetch_result: Result, + dev_mode: bool, +) -> Option> +where + I: IntoIterator, +{ + match fetch_result { + Ok(events) => Some(events.into_iter().collect()), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => None, + Err(e) => { + if dev_mode { + error!("🖱️❌ mouse read err: {e}"); + } + None + } + } +} + +fn log_event_batch(dev_mode: bool, device_name: Option<&str>, evts: &[InputEvent]) { + if dev_mode && !evts.is_empty() { + trace!("🖱️ {} evts from {}", evts.len(), device_name.unwrap_or("?")); + } +} + pub struct MouseAggregator { dev: Device, tx: Sender, @@ -98,124 +297,12 @@ impl MouseAggregator { self.sending_disabled = !send; } - #[cfg(not(coverage))] pub fn process_events(&mut self) { - let evts: Vec = match self.dev.fetch_events() { - Ok(it) => it.collect(), - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return, - Err(e) => { - if self.dev_mode { - error!("🖱️❌ mouse read err: {e}"); - } - return; - } + let Some(evts) = collect_fetched_events(self.dev.fetch_events(), self.dev_mode) else { + return; }; - - if self.dev_mode && !evts.is_empty() { - trace!( - "🖱️ {} evts from {}", - evts.len(), - self.dev.name().unwrap_or("?") - ); - } - - for e in evts { - match e.event_type() { - EventType::KEY => match e.code() { - c if c == KeyCode::BTN_LEFT.0 => self.set_btn(0, e.value()), - c if c == KeyCode::BTN_RIGHT.0 => self.set_btn(1, e.value()), - c if c == KeyCode::BTN_MIDDLE.0 => self.set_btn(2, e.value()), - c if c == KeyCode::BTN_TOUCH.0 => { - self.touch_guarded = true; - self.touch_active = e.value() != 0; - if !self.touch_active { - self.last_abs_x = None; - self.last_abs_y = None; - } - self.set_btn(0, e.value()); - } - _ => {} - }, - EventType::RELATIVE => match e.code() { - c if c == RelativeAxisCode::REL_X.0 => { - self.dx = self.dx.saturating_add(e.value().clamp(-127, 127) as i8) - } - c if c == RelativeAxisCode::REL_Y.0 => { - self.dy = self.dy.saturating_add(e.value().clamp(-127, 127) as i8) - } - c if c == RelativeAxisCode::REL_WHEEL.0 => { - self.wheel = self.wheel.saturating_add(e.value().clamp(-1, 1) as i8) - } - _ => {} - }, - EventType::ABSOLUTE => match e.code() { - c if c == AbsoluteAxisCode::ABS_X.0 - || c == AbsoluteAxisCode::ABS_MT_POSITION_X.0 => - { - if self.touch_guarded && !self.touch_active { - self.last_abs_x = Some(e.value()); - continue; - } - if let Some(prev) = self.last_abs_x { - if !self.has_touch_state { - let delta = (e.value() - prev).abs(); - if delta > self.abs_jump_x { - self.last_abs_x = Some(e.value()); - continue; - } - } - let delta = (e.value() - prev) / self.abs_scale; - if delta != 0 { - self.dx = self.dx.saturating_add(delta.clamp(-127, 127) as i8); - } - } - self.last_abs_x = Some(e.value()); - } - c if c == AbsoluteAxisCode::ABS_Y.0 - || c == AbsoluteAxisCode::ABS_MT_POSITION_Y.0 => - { - if self.touch_guarded && !self.touch_active { - self.last_abs_y = Some(e.value()); - continue; - } - if let Some(prev) = self.last_abs_y { - if !self.has_touch_state { - let delta = (e.value() - prev).abs(); - if delta > self.abs_jump_y { - self.last_abs_y = Some(e.value()); - continue; - } - } - let delta = (e.value() - prev) / self.abs_scale; - if delta != 0 { - self.dy = self.dy.saturating_add(delta.clamp(-127, 127) as i8); - } - } - self.last_abs_y = Some(e.value()); - } - c if c == AbsoluteAxisCode::ABS_MT_TRACKING_ID.0 => { - if e.value() < 0 { - self.touch_guarded = true; - self.touch_active = false; - self.last_abs_x = None; - self.last_abs_y = None; - } else { - self.touch_guarded = true; - self.touch_active = true; - } - } - _ => {} - }, - EventType::SYNCHRONIZATION => self.flush(), - _ => {} - } - } - } - - #[cfg(coverage)] - pub fn process_events(&mut self) { - let _ = self.dev.fetch_events(); - self.flush(); + log_event_batch(self.dev_mode, self.dev.name(), &evts); + self.runtime().replay_events(evts); } pub fn reset_state(&mut self) { @@ -234,49 +321,27 @@ impl MouseAggregator { } fn flush(&mut self) { - if self.buttons == self.last_buttons && Instant::now() < self.next_send { - return; - } - self.next_send = Instant::now() + SEND_INTERVAL; - - let pkt = [ - self.buttons, - self.dx.clamp(-127, 127) as u8, - self.dy.clamp(-127, 127) as u8, - self.wheel as u8, - ]; - - if !self.sending_disabled { - #[cfg(not(coverage))] - if let Err(tokio::sync::broadcast::error::SendError(_)) = - self.tx.send(MouseReport { data: pkt.to_vec() }) - { - if self.dev_mode { - warn!("❌🖱️ no HID receiver (mouse)"); - } - } else if self.dev_mode { - debug!("📤🖱️ mouse {:?}", pkt); - } - - #[cfg(coverage)] - { - let _ = self.tx.send(MouseReport { data: pkt.to_vec() }); - } - } - - self.dx = 0; - self.dy = 0; - self.wheel = 0; - self.last_buttons = self.buttons; + self.runtime().flush(); } #[inline] + #[allow(dead_code)] fn set_btn(&mut self, bit: u8, val: i32) { - if val != 0 { - self.buttons |= 1 << bit - } else { - self.buttons &= !(1 << bit) + MouseEventState { + buttons: &mut self.buttons, + dx: &mut self.dx, + dy: &mut self.dy, + wheel: &mut self.wheel, + last_abs_x: &mut self.last_abs_x, + last_abs_y: &mut self.last_abs_y, + abs_scale: self.abs_scale, + abs_jump_x: self.abs_jump_x, + abs_jump_y: self.abs_jump_y, + has_touch_state: self.has_touch_state, + touch_guarded: &mut self.touch_guarded, + touch_active: &mut self.touch_active, } + .set_btn(bit, val); } #[cfg(not(coverage))] @@ -305,6 +370,30 @@ impl MouseAggregator { fn abs_jump_threshold(_dev: &Device, _codes: &[AbsoluteAxisCode], abs_scale: i32) -> i32 { (abs_scale * 40).max(50) } + + fn runtime(&mut self) -> MouseRuntime<'_> { + MouseRuntime { + tx: &self.tx, + dev_mode: self.dev_mode, + sending_disabled: self.sending_disabled, + next_send: &mut self.next_send, + last_buttons: &mut self.last_buttons, + event_state: MouseEventState { + buttons: &mut self.buttons, + dx: &mut self.dx, + dy: &mut self.dy, + wheel: &mut self.wheel, + last_abs_x: &mut self.last_abs_x, + last_abs_y: &mut self.last_abs_y, + abs_scale: self.abs_scale, + abs_jump_x: self.abs_jump_x, + abs_jump_y: self.abs_jump_y, + has_touch_state: self.has_touch_state, + touch_guarded: &mut self.touch_guarded, + touch_active: &mut self.touch_active, + }, + } + } } impl Drop for MouseAggregator { @@ -315,3 +404,7 @@ impl Drop for MouseAggregator { }); } } + +#[cfg(test)] +#[path = "mouse_event_contract_tests.rs"] +mod mouse_event_contract_tests; diff --git a/client/src/input/mouse_event_contract_tests.rs b/client/src/input/mouse_event_contract_tests.rs new file mode 100644 index 0000000..83476e4 --- /dev/null +++ b/client/src/input/mouse_event_contract_tests.rs @@ -0,0 +1,439 @@ +//! Unit coverage for mouse event state transitions. +//! +//! Scope: exercise the private event decoder used by `MouseAggregator`. +//! Targets: `client/src/input/mouse.rs`. +//! Why: the quality gate tracks the real crate source, so the core event +//! handling needs deterministic coverage without depending on `/dev/uinput`. + +use super::*; +use serial_test::serial; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +fn open_virtual_node(vdev: &mut evdev::uinput::VirtualDevice) -> Option { + for _ in 0..40 { + if let Ok(mut nodes) = vdev.enumerate_dev_nodes_blocking() { + if let Some(Ok(path)) = nodes.next() { + return Some(path); + } + } + thread::sleep(Duration::from_millis(10)); + } + None +} + +fn open_virtual_device(vdev: &mut evdev::uinput::VirtualDevice) -> Option { + let node = open_virtual_node(vdev)?; + let dev = evdev::Device::open(node).ok()?; + dev.set_nonblocking(true).ok()?; + Some(dev) +} + +fn build_relative_mouse(name: &str) -> Option<(evdev::uinput::VirtualDevice, evdev::Device)> { + let mut keys = evdev::AttributeSet::::new(); + keys.insert(evdev::KeyCode::BTN_LEFT); + keys.insert(evdev::KeyCode::BTN_RIGHT); + keys.insert(evdev::KeyCode::BTN_MIDDLE); + + let mut rel = evdev::AttributeSet::::new(); + rel.insert(evdev::RelativeAxisCode::REL_X); + rel.insert(evdev::RelativeAxisCode::REL_Y); + rel.insert(evdev::RelativeAxisCode::REL_WHEEL); + + let mut vdev = evdev::uinput::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)) +} + +struct StateHarness { + buttons: u8, + dx: i8, + dy: i8, + wheel: i8, + last_abs_x: Option, + last_abs_y: Option, + abs_scale: i32, + abs_jump_x: i32, + abs_jump_y: i32, + has_touch_state: bool, + touch_guarded: bool, + touch_active: bool, +} + +impl StateHarness { + fn new() -> Self { + Self { + buttons: 0, + dx: 0, + dy: 0, + wheel: 0, + last_abs_x: None, + last_abs_y: None, + abs_scale: 8, + abs_jump_x: 120, + abs_jump_y: 120, + has_touch_state: false, + touch_guarded: false, + touch_active: true, + } + } + + fn with_touch_state() -> Self { + Self { + has_touch_state: true, + ..Self::new() + } + } + + fn state(&mut self) -> MouseEventState<'_> { + MouseEventState { + buttons: &mut self.buttons, + dx: &mut self.dx, + dy: &mut self.dy, + wheel: &mut self.wheel, + last_abs_x: &mut self.last_abs_x, + last_abs_y: &mut self.last_abs_y, + abs_scale: self.abs_scale, + abs_jump_x: self.abs_jump_x, + abs_jump_y: self.abs_jump_y, + has_touch_state: self.has_touch_state, + touch_guarded: &mut self.touch_guarded, + touch_active: &mut self.touch_active, + } + } +} + +#[test] +fn key_events_update_button_bits_and_touch_release_clears_origins() { + let mut harness = StateHarness::with_touch_state(); + harness.last_abs_x = Some(100); + harness.last_abs_y = Some(120); + + { + let mut state = harness.state(); + state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_LEFT.0, 1)); + state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_RIGHT.0, 1)); + state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_MIDDLE.0, 1)); + state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_TOUCH.0, 1)); + state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::KEY_A.0, 1)); + } + + assert_eq!(harness.buttons & 0b111, 0b111); + assert!(harness.touch_guarded); + assert!(harness.touch_active); + + { + let mut state = harness.state(); + state.apply_event(&InputEvent::new(EventType::KEY.0, KeyCode::BTN_TOUCH.0, 0)); + } + + assert!(!harness.touch_active); + assert!(harness.last_abs_x.is_none()); + assert!(harness.last_abs_y.is_none()); + assert_eq!(harness.buttons & 0b001, 0); +} + +#[test] +fn relative_events_accumulate_motion_and_ignore_unknown_codes() { + let mut harness = StateHarness::new(); + + { + let mut state = harness.state(); + state.apply_event(&InputEvent::new( + EventType::RELATIVE.0, + RelativeAxisCode::REL_X.0, + 11, + )); + state.apply_event(&InputEvent::new( + EventType::RELATIVE.0, + RelativeAxisCode::REL_Y.0, + -7, + )); + state.apply_event(&InputEvent::new( + EventType::RELATIVE.0, + RelativeAxisCode::REL_WHEEL.0, + 1, + )); + state.apply_event(&InputEvent::new( + EventType::RELATIVE.0, + RelativeAxisCode::REL_RX.0, + 42, + )); + } + + assert_eq!(harness.dx, 11); + assert_eq!(harness.dy, -7); + assert_eq!(harness.wheel, 1); +} + +#[test] +fn absolute_events_apply_scaled_delta_and_ignore_large_jumps_without_touch_state() { + let mut harness = StateHarness::new(); + harness.last_abs_x = Some(100); + harness.last_abs_y = Some(100); + + { + let mut state = harness.state(); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_X.0, + 140, + )); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_Y.0, + 180, + )); + } + + assert_eq!(harness.dx, 5); + assert_eq!(harness.dy, 10); + assert_eq!(harness.last_abs_x, Some(140)); + assert_eq!(harness.last_abs_y, Some(180)); + + harness.dx = 0; + harness.dy = 0; + + { + let mut state = harness.state(); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_X.0, + 900, + )); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_Y.0, + 900, + )); + } + + assert_eq!(harness.dx, 0); + assert_eq!(harness.dy, 0); + assert_eq!(harness.last_abs_x, Some(900)); + assert_eq!(harness.last_abs_y, Some(900)); +} + +#[test] +fn first_absolute_samples_only_seed_origin_without_motion() { + let mut harness = StateHarness::new(); + + { + let mut state = harness.state(); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_X.0, + 320, + )); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_Y.0, + 640, + )); + } + + assert_eq!(harness.dx, 0); + assert_eq!(harness.dy, 0); + assert_eq!(harness.last_abs_x, Some(320)); + assert_eq!(harness.last_abs_y, Some(640)); +} + +#[test] +fn touch_guarded_absolute_updates_only_refresh_origins_until_touch_returns() { + let mut harness = StateHarness::with_touch_state(); + harness.touch_guarded = true; + harness.touch_active = false; + harness.last_abs_x = Some(10); + harness.last_abs_y = Some(20); + + { + let mut state = harness.state(); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_MT_POSITION_X.0, + 50, + )); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_MT_POSITION_Y.0, + 70, + )); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_MT_TRACKING_ID.0, + 3, + )); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_MT_POSITION_X.0, + 82, + )); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_MT_POSITION_Y.0, + 102, + )); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_PRESSURE.0, + 999, + )); + } + + assert!(harness.touch_guarded); + assert!(harness.touch_active); + assert_eq!(harness.last_abs_x, Some(82)); + assert_eq!(harness.last_abs_y, Some(102)); + assert_eq!(harness.dx, 4); + assert_eq!(harness.dy, 4); +} + +#[test] +fn tracking_release_and_unhandled_event_types_stay_safe() { + let mut harness = StateHarness::with_touch_state(); + harness.last_abs_x = Some(33); + harness.last_abs_y = Some(44); + + { + let mut state = harness.state(); + state.apply_event(&InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_MT_TRACKING_ID.0, + -1, + )); + state.apply_event(&InputEvent::new(EventType::SWITCH.0, 0, 1)); + } + + assert!(harness.touch_guarded); + assert!(!harness.touch_active); + assert!(harness.last_abs_x.is_none()); + assert!(harness.last_abs_y.is_none()); +} + +#[test] +fn replay_events_applies_regular_events_and_flushes_on_sync_boundaries() { + let mut harness = StateHarness::new(); + let (tx, mut rx) = tokio::sync::broadcast::channel(8); + let mut next_send = Instant::now() - Duration::from_millis(5); + let mut last_buttons = 0_u8; + + MouseRuntime { + tx: &tx, + dev_mode: true, + sending_disabled: false, + next_send: &mut next_send, + last_buttons: &mut last_buttons, + event_state: harness.state(), + } + .replay_events(vec![ + InputEvent::new(EventType::KEY.0, KeyCode::BTN_LEFT.0, 1), + InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_X.0, 9), + InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0), + ]); + + let pkt = rx.try_recv().expect("sync flush packet"); + assert_eq!(pkt.data[0], 1); + assert_eq!(pkt.data[1], 9); + assert_eq!(pkt.data[2], 0); + assert_eq!(pkt.data[3], 0); + assert_eq!(harness.dx, 0); + assert_eq!(harness.dy, 0); + assert_eq!(harness.wheel, 0); + assert_eq!(last_buttons, 1); +} + +#[test] +fn collect_fetched_events_handles_ok_would_block_and_other_errors() { + let ok_events = collect_fetched_events( + Ok(vec![InputEvent::new( + EventType::RELATIVE.0, + RelativeAxisCode::REL_X.0, + 1, + )]), + false, + ) + .expect("ok result should yield an event batch"); + assert_eq!(ok_events.len(), 1); + + let would_block = collect_fetched_events::>( + Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "synthetic would-block", + )), + false, + ); + assert!(would_block.is_none()); + + let other_error = collect_fetched_events::>( + Err(std::io::Error::other("synthetic read failure")), + true, + ); + assert!(other_error.is_none()); +} + +#[test] +fn log_event_batch_tolerates_empty_and_populated_batches() { + log_event_batch(false, Some("quiet-mouse"), &[]); + log_event_batch( + true, + Some("trace-mouse"), + &[InputEvent::new( + EventType::RELATIVE.0, + RelativeAxisCode::REL_Y.0, + 4, + )], + ); +} + +#[test] +#[serial] +fn process_events_emits_live_relative_packets_for_the_real_crate_path() { + let Some((mut vdev, dev)) = build_relative_mouse("lesavka-unit-mouse-rel") else { + return; + }; + + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + let mut agg = MouseAggregator::new(dev, true, tx); + + vdev.emit(&[ + InputEvent::new(EventType::KEY.0, KeyCode::BTN_LEFT.0, 1), + InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_X.0, 12), + InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_Y.0, -5), + InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_WHEEL.0, 1), + ]) + .expect("emit relative frame"); + + thread::sleep(Duration::from_millis(25)); + agg.process_events(); + + let pkt = rx.try_recv().expect("mouse packet"); + assert_eq!(pkt.data[0], 1); + assert_eq!(pkt.data[1], 12); + assert_eq!(pkt.data[2], (-5_i8) as u8); + assert_eq!(pkt.data[3], 1); +} + +#[test] +#[serial] +fn process_events_handles_disconnected_virtual_devices_without_panicking() { + let Some((vdev, dev)) = build_relative_mouse("lesavka-unit-mouse-err") else { + return; + }; + + let (tx, _rx) = tokio::sync::broadcast::channel(4); + let mut agg = MouseAggregator::new(dev, true, tx); + + drop(vdev); + thread::sleep(Duration::from_millis(40)); + agg.process_events(); +} diff --git a/scripts/ci/quality_gate_baseline.json b/scripts/ci/quality_gate_baseline.json index faacbcb..b2d468d 100644 --- a/scripts/ci/quality_gate_baseline.json +++ b/scripts/ci/quality_gate_baseline.json @@ -89,8 +89,8 @@ "loc": 398 }, "client/src/input/mouse.rs": { - "line_percent": 100.0, - "loc": 317 + "line_percent": 98.85, + "loc": 410 }, "client/src/launcher/clipboard.rs": { "line_percent": 100.0, @@ -132,6 +132,10 @@ "line_percent": 100.0, "loc": 184 }, + "client/src/launcher/ui/session_preview_coverage.rs": { + "line_percent": 100.0, + "loc": 7 + }, "client/src/layout.rs": { "line_percent": 97.56, "loc": 78 diff --git a/testing/tests/client_mouse_include_contract.rs b/testing/tests/client_mouse_include_contract.rs index fca29c9..5d98c59 100644 --- a/testing/tests/client_mouse_include_contract.rs +++ b/testing/tests/client_mouse_include_contract.rs @@ -158,9 +158,6 @@ mod mouse_contract { #[test] #[serial] fn relative_events_emit_button_motion_and_wheel_packets() { - if cfg!(coverage) { - return; - } let Some((mut vdev, dev)) = build_relative_mouse("lesavka-include-mouse-rel") else { return; }; @@ -205,9 +202,6 @@ mod mouse_contract { #[test] #[serial] fn touch_tracking_updates_touch_state_and_clears_abs_origins_on_release() { - if cfg!(coverage) { - return; - } let Some((mut vdev, dev)) = build_touch_device("lesavka-include-mouse-touch") else { return; }; @@ -342,9 +336,6 @@ mod mouse_contract { #[test] #[serial] fn absolute_motion_ignores_large_jumps_without_touch_state() { - if cfg!(coverage) { - return; - } let Some((mut vdev, dev)) = build_absolute_mouse_without_touch("lesavka-include-abs-jump") else { return; @@ -382,9 +373,6 @@ mod mouse_contract { #[test] #[serial] fn absolute_motion_applies_scaled_delta_within_threshold() { - if cfg!(coverage) { - return; - } let Some((mut vdev, dev)) = build_absolute_mouse_without_touch("lesavka-include-abs-delta") else { return; @@ -418,9 +406,6 @@ mod mouse_contract { #[test] #[serial] fn touch_guarded_inactive_abs_events_only_update_origins() { - if cfg!(coverage) { - return; - } let Some((mut vdev, dev)) = build_touch_device("lesavka-include-touch-guarded") else { return; };