Skip to main content

chromiumoxide/
mouse.rs

1//! Smart mouse movement with human-like bezier curves and position tracking.
2//!
3//! This module provides realistic mouse movement simulation using cubic bezier
4//! curves, configurable jitter, overshoot, and easing. The [`SmartMouse`] struct
5//! tracks the current mouse position across operations so that every movement
6//! starts from where the cursor actually is.
7
8use crate::layout::Point;
9use rand::RngExt;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::Duration;
12
13/// Configuration for smart mouse movement behavior.
14#[derive(Debug, Clone)]
15pub struct SmartMouseConfig {
16    /// Number of intermediate steps for movement (higher = smoother).
17    /// Used directly when [`auto_size`](Self::auto_size) is `false`. With
18    /// `auto_size` enabled the step count is derived from move distance
19    /// instead and this field is ignored. Default: 25.
20    pub steps: usize,
21    /// Overshoot factor past the target (0.0 = none, 1.0 = full distance). Default: 0.15.
22    pub overshoot: f64,
23    /// Per-step jitter in CSS pixels. Default: 1.5.
24    pub jitter: f64,
25    /// Target delay between movement steps in milliseconds. With
26    /// `auto_size`, this is the *target* spacing — the actual per-step
27    /// delay is the auto-sized total duration divided by the auto-sized
28    /// step count, which can differ when the step count clamps. Default: 8.
29    pub step_delay_ms: u64,
30    /// Whether to apply ease-in-out timing (acceleration/deceleration). Default: true.
31    pub easing: bool,
32    /// Scale step count and total duration to move distance using a
33    /// Fitts'-law-style formula. When `false`, every move uses exactly
34    /// [`steps`](Self::steps) intermediate points spaced at
35    /// [`step_delay_ms`](Self::step_delay_ms) regardless of distance —
36    /// which makes every gesture take the same wall-clock time and is
37    /// itself a detection signal. Default: true.
38    pub auto_size: bool,
39    /// Lower bound on auto-sized total move duration in milliseconds. Default: 100.
40    pub min_duration_ms: u64,
41    /// Upper bound on auto-sized total move duration in milliseconds. Default: 800.
42    pub max_duration_ms: u64,
43    /// Inclusive range for the randomized pause between the final
44    /// `mouseMoved` of a move and the `mousePressed` of the click that
45    /// follows, in milliseconds. `None` disables the dwell entirely;
46    /// `Some((0, 0))` is also treated as disabled. Default:
47    /// `Some((40, 120))` — short enough to feel responsive but long
48    /// enough to break the move→press timing fingerprint that common
49    /// antibot heuristics flag.
50    pub pre_click_dwell_ms: Option<(u64, u64)>,
51}
52
53impl Default for SmartMouseConfig {
54    fn default() -> Self {
55        Self {
56            steps: 25,
57            overshoot: 0.15,
58            jitter: 1.5,
59            step_delay_ms: 8,
60            easing: true,
61            auto_size: true,
62            min_duration_ms: 100,
63            max_duration_ms: 800,
64            pre_click_dwell_ms: Some((40, 120)),
65        }
66    }
67}
68
69/// Lower clamp on auto-sized step count. A few intermediate points are
70/// enough to produce a curved trajectory; below this the path looks like
71/// a straight teleport.
72const MIN_AUTO_STEPS: usize = 6;
73/// Upper clamp on auto-sized step count. Real OS pointer integration
74/// rarely emits more than this for a single gesture, and going higher
75/// just spends more wall-clock for diminishing realism.
76const MAX_AUTO_STEPS: usize = 40;
77
78/// Compute the target wall-clock duration for a move of the given
79/// distance, using a Fitts'-law-style formula `T = a + b · log₂(D/W + 1)`.
80/// Constants are tuned so common UI clicks land in the 150-350ms range
81/// and full-screen sweeps stay under the configured upper bound.
82fn fitts_total_ms(distance: f64, config: &SmartMouseConfig) -> f64 {
83    const A_MS: f64 = 80.0;
84    const B_MS: f64 = 110.0;
85    const W_PX: f64 = 40.0;
86    let raw = A_MS + B_MS * (distance / W_PX + 1.0).log2();
87    let lo = config.min_duration_ms as f64;
88    let hi = (config.max_duration_ms as f64).max(lo);
89    raw.clamp(lo, hi)
90}
91
92/// A single step in a mouse movement path.
93#[derive(Debug, Clone)]
94pub struct MovementStep {
95    /// The point to move to.
96    pub point: Point,
97    /// Delay before dispatching the next step.
98    pub delay: Duration,
99}
100
101/// Evaluate a cubic bezier curve at parameter `t` in [0, 1].
102///
103/// Given control points P0, P1, P2, P3:
104/// B(t) = (1-t)³·P0 + 3(1-t)²·t·P1 + 3(1-t)·t²·P2 + t³·P3
105fn cubic_bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
106    let inv = 1.0 - t;
107    let inv2 = inv * inv;
108    let inv3 = inv2 * inv;
109    let t2 = t * t;
110    let t3 = t2 * t;
111
112    Point {
113        x: inv3 * p0.x + 3.0 * inv2 * t * p1.x + 3.0 * inv * t2 * p2.x + t3 * p3.x,
114        y: inv3 * p0.y + 3.0 * inv2 * t * p1.y + 3.0 * inv * t2 * p2.y + t3 * p3.y,
115    }
116}
117
118/// Ease-in-out cubic function for natural acceleration/deceleration.
119fn ease_in_out(t: f64) -> f64 {
120    if t < 0.5 {
121        4.0 * t * t * t
122    } else {
123        1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
124    }
125}
126
127/// Generate a human-like mouse movement path from `from` to `to`.
128///
129/// Returns a series of [`MovementStep`]s that, when dispatched sequentially,
130/// produce a realistic-looking cursor trajectory with natural timing.
131pub fn generate_path(from: Point, to: Point, config: &SmartMouseConfig) -> Vec<MovementStep> {
132    let mut rng = rand::rng();
133
134    let dx = to.x - from.x;
135    let dy = to.y - from.y;
136    let distance = (dx * dx + dy * dy).sqrt();
137
138    // For very short moves, just go directly
139    if distance < 2.0 {
140        return vec![MovementStep {
141            point: to,
142            delay: Duration::from_millis(config.step_delay_ms),
143        }];
144    }
145
146    // Resolve the path's step count and per-step delay. With `auto_size`,
147    // both are functions of distance: the total duration follows
148    // Fitts'-law, the step count is the duration divided by the
149    // configured target spacing (clamped to a human-plausible range),
150    // and the actual per-step delay is back-derived so total ≈ Fitts.
151    // Without `auto_size`, the legacy fixed-step / fixed-delay shape is
152    // preserved exactly.
153    let (steps, step_delay_ms) = if config.auto_size {
154        let total_ms = fitts_total_ms(distance, config);
155        let target = (config.step_delay_ms as f64).max(1.0);
156        let raw = (total_ms / target).round() as usize;
157        let s = raw.clamp(MIN_AUTO_STEPS, MAX_AUTO_STEPS);
158        (s, total_ms / s as f64)
159    } else {
160        (config.steps.max(2), config.step_delay_ms as f64)
161    };
162
163    // Perpendicular unit vector for control point offsets
164    let (perp_x, perp_y) = if distance > 0.001 {
165        (-dy / distance, dx / distance)
166    } else {
167        (0.0, 1.0)
168    };
169
170    // Random control point offsets (curved path)
171    let spread = distance * 0.3;
172    let offset1: f64 = rng.random_range(-spread..spread);
173    let offset2: f64 = rng.random_range(-spread..spread);
174
175    let cp1 = Point {
176        x: from.x + dx * 0.25 + perp_x * offset1,
177        y: from.y + dy * 0.25 + perp_y * offset1,
178    };
179    let cp2 = Point {
180        x: from.x + dx * 0.75 + perp_x * offset2,
181        y: from.y + dy * 0.75 + perp_y * offset2,
182    };
183
184    // Overshoot is human-shaped only on long sweeps. On short moves
185    // (clicking a button a few hundred px away) people don't overshoot,
186    // so emitting an overshoot+correction there is itself a tell.
187    let should_overshoot = config.overshoot > 0.0 && distance > 200.0;
188
189    let overshoot_target = if should_overshoot {
190        let overshoot_amount = distance * config.overshoot * rng.random_range(0.5..1.5);
191        Point {
192            x: to.x + (dx / distance) * overshoot_amount,
193            y: to.y + (dy / distance) * overshoot_amount,
194        }
195    } else {
196        to
197    };
198
199    let main_steps = if should_overshoot {
200        (steps as f64 * 0.85) as usize
201    } else {
202        steps
203    };
204
205    let mut path = Vec::with_capacity(steps + 2);
206
207    // Main bezier path
208    let end = if should_overshoot {
209        overshoot_target
210    } else {
211        to
212    };
213
214    for i in 1..=main_steps {
215        let raw_t = i as f64 / main_steps as f64;
216        let t = if config.easing {
217            ease_in_out(raw_t)
218        } else {
219            raw_t
220        };
221
222        let mut p = cubic_bezier(from, cp1, cp2, end, t);
223
224        // Add jitter except near the end
225        if config.jitter > 0.0 && i < main_steps.saturating_sub(2) {
226            p.x += rng.random_range(-config.jitter..config.jitter);
227            p.y += rng.random_range(-config.jitter..config.jitter);
228        }
229
230        // Vary delay for natural timing
231        let delay_variation: f64 = rng.random_range(0.7..1.3);
232        let delay = Duration::from_millis((step_delay_ms * delay_variation) as u64);
233
234        path.push(MovementStep { point: p, delay });
235    }
236
237    // Correction steps back to actual target after overshoot
238    if should_overshoot {
239        let correction_steps = steps.saturating_sub(main_steps).max(3);
240        let last = path.last().map(|s| s.point).unwrap_or(overshoot_target);
241
242        for i in 1..=correction_steps {
243            let t = i as f64 / correction_steps as f64;
244            let t = if config.easing { ease_in_out(t) } else { t };
245
246            let p = Point {
247                x: last.x + (to.x - last.x) * t,
248                y: last.y + (to.y - last.y) * t,
249            };
250
251            let delay = Duration::from_millis((step_delay_ms * 0.6) as u64);
252            path.push(MovementStep { point: p, delay });
253        }
254    }
255
256    // Ensure the final point is exactly the target
257    if let Some(last) = path.last_mut() {
258        last.point = to;
259    }
260
261    path
262}
263
264/// Tracks the current mouse position and generates human-like movement paths.
265///
266/// Uses lock-free atomics for interior mutability — safe to use behind `Arc`
267/// and `&self` references without any mutex. Use this alongside CDP
268/// `Input.dispatchMouseEvent` calls so that every movement starts from
269/// the real cursor location instead of teleporting.
270pub struct SmartMouse {
271    pos_x: AtomicU64,
272    pos_y: AtomicU64,
273    config: SmartMouseConfig,
274}
275
276impl std::fmt::Debug for SmartMouse {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        let pos = self.position();
279        f.debug_struct("SmartMouse")
280            .field("position", &pos)
281            .field("config", &self.config)
282            .finish()
283    }
284}
285
286impl SmartMouse {
287    /// Create a new `SmartMouse` starting at (0, 0) with default configuration.
288    pub fn new() -> Self {
289        Self {
290            pos_x: AtomicU64::new(0.0_f64.to_bits()),
291            pos_y: AtomicU64::new(0.0_f64.to_bits()),
292            config: SmartMouseConfig::default(),
293        }
294    }
295
296    /// Create a `SmartMouse` with custom configuration.
297    pub fn with_config(config: SmartMouseConfig) -> Self {
298        Self {
299            pos_x: AtomicU64::new(0.0_f64.to_bits()),
300            pos_y: AtomicU64::new(0.0_f64.to_bits()),
301            config,
302        }
303    }
304
305    /// Get the current tracked mouse position.
306    pub fn position(&self) -> Point {
307        Point {
308            x: f64::from_bits(self.pos_x.load(Ordering::Relaxed)),
309            y: f64::from_bits(self.pos_y.load(Ordering::Relaxed)),
310        }
311    }
312
313    /// Set the mouse position directly (e.g., after a teleport or click).
314    pub fn set_position(&self, point: Point) {
315        self.pos_x.store(point.x.to_bits(), Ordering::Relaxed);
316        self.pos_y.store(point.y.to_bits(), Ordering::Relaxed);
317    }
318
319    /// Get the movement configuration.
320    pub fn config(&self) -> &SmartMouseConfig {
321        &self.config
322    }
323
324    /// Generate a movement path from the current position to `target`.
325    ///
326    /// This updates the tracked position to `target` and returns a series of
327    /// [`MovementStep`]s for dispatching intermediate `MouseMoved` events.
328    pub fn path_to(&self, target: Point) -> Vec<MovementStep> {
329        let from = self.position();
330        self.set_position(target);
331        generate_path(from, target, &self.config)
332    }
333
334    /// Sample a randomized pre-click dwell duration from the configured
335    /// range, or `None` if the dwell is disabled.
336    ///
337    /// The dwell is the pause between the final `mouseMoved` of an
338    /// approach and the `mousePressed` of the click. Real users always
339    /// take at least a few tens of milliseconds to commit; bot drivers
340    /// commonly press in the same task tick. Inserting this gap is one
341    /// of the cheaper detection-shape wins available.
342    pub fn pre_click_dwell(&self) -> Option<Duration> {
343        let (lo, hi) = self.config.pre_click_dwell_ms?;
344        if hi == 0 {
345            return None;
346        }
347        let lo = lo.min(hi);
348        let ms = if lo == hi {
349            lo
350        } else {
351            let mut rng = rand::rng();
352            rng.random_range(lo..=hi)
353        };
354        Some(Duration::from_millis(ms))
355    }
356}
357
358impl Default for SmartMouse {
359    fn default() -> Self {
360        Self::new()
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_cubic_bezier_endpoints() {
370        let p0 = Point::new(0.0, 0.0);
371        let p1 = Point::new(25.0, 50.0);
372        let p2 = Point::new(75.0, 50.0);
373        let p3 = Point::new(100.0, 100.0);
374
375        let start = cubic_bezier(p0, p1, p2, p3, 0.0);
376        assert!((start.x - p0.x).abs() < 1e-10);
377        assert!((start.y - p0.y).abs() < 1e-10);
378
379        let end = cubic_bezier(p0, p1, p2, p3, 1.0);
380        assert!((end.x - p3.x).abs() < 1e-10);
381        assert!((end.y - p3.y).abs() < 1e-10);
382    }
383
384    #[test]
385    fn test_cubic_bezier_midpoint() {
386        // Straight line: control points on the line
387        let p0 = Point::new(0.0, 0.0);
388        let p1 = Point::new(33.3, 33.3);
389        let p2 = Point::new(66.6, 66.6);
390        let p3 = Point::new(100.0, 100.0);
391
392        let mid = cubic_bezier(p0, p1, p2, p3, 0.5);
393        // Should be approximately (50, 50) for a straight-line bezier
394        assert!((mid.x - 50.0).abs() < 1.0);
395        assert!((mid.y - 50.0).abs() < 1.0);
396    }
397
398    #[test]
399    fn test_ease_in_out_boundaries() {
400        assert!((ease_in_out(0.0)).abs() < 1e-10);
401        assert!((ease_in_out(1.0) - 1.0).abs() < 1e-10);
402    }
403
404    #[test]
405    fn test_ease_in_out_midpoint() {
406        let mid = ease_in_out(0.5);
407        assert!((mid - 0.5).abs() < 1e-10);
408    }
409
410    #[test]
411    fn test_ease_in_out_monotonic() {
412        let mut prev = 0.0;
413        for i in 1..=100 {
414            let t = i as f64 / 100.0;
415            let val = ease_in_out(t);
416            assert!(
417                val >= prev,
418                "ease_in_out should be monotonically increasing"
419            );
420            prev = val;
421        }
422    }
423
424    #[test]
425    fn test_generate_path_ends_at_target() {
426        let from = Point::new(10.0, 20.0);
427        let to = Point::new(500.0, 300.0);
428        let config = SmartMouseConfig::default();
429
430        let path = generate_path(from, to, &config);
431
432        assert!(!path.is_empty());
433        let last = &path.last().unwrap().point;
434        assert!(
435            (last.x - to.x).abs() < 1e-10 && (last.y - to.y).abs() < 1e-10,
436            "path must end exactly at target, got ({}, {})",
437            last.x,
438            last.y
439        );
440    }
441
442    #[test]
443    fn test_generate_path_short_distance() {
444        let from = Point::new(100.0, 100.0);
445        let to = Point::new(100.5, 100.5);
446        let config = SmartMouseConfig::default();
447
448        let path = generate_path(from, to, &config);
449
450        assert_eq!(
451            path.len(),
452            1,
453            "very short moves should produce a single step"
454        );
455        assert!((path[0].point.x - to.x).abs() < 1e-10);
456        assert!((path[0].point.y - to.y).abs() < 1e-10);
457    }
458
459    #[test]
460    fn test_generate_path_no_overshoot() {
461        let from = Point::new(0.0, 0.0);
462        let to = Point::new(200.0, 200.0);
463        let config = SmartMouseConfig {
464            overshoot: 0.0,
465            auto_size: false,
466            ..Default::default()
467        };
468
469        let path = generate_path(from, to, &config);
470        assert_eq!(path.len(), config.steps);
471    }
472
473    #[test]
474    fn test_generate_path_no_jitter() {
475        let from = Point::new(0.0, 0.0);
476        let to = Point::new(200.0, 200.0);
477        let config = SmartMouseConfig {
478            jitter: 0.0,
479            overshoot: 0.0,
480            easing: false,
481            ..Default::default()
482        };
483
484        // Without jitter or easing, successive runs with same from/to
485        // should produce paths that lie on a bezier curve (no random noise
486        // except from control point placement).
487        let path = generate_path(from, to, &config);
488        assert!(!path.is_empty());
489        let last = &path.last().unwrap().point;
490        assert!((last.x - to.x).abs() < 1e-10);
491        assert!((last.y - to.y).abs() < 1e-10);
492    }
493
494    #[test]
495    fn test_generate_path_step_count_with_overshoot() {
496        let from = Point::new(0.0, 0.0);
497        let to = Point::new(500.0, 500.0);
498        let config = SmartMouseConfig {
499            steps: 30,
500            overshoot: 0.2,
501            auto_size: false,
502            ..Default::default()
503        };
504
505        let path = generate_path(from, to, &config);
506        // With overshoot: main_steps (~85%) + correction_steps (~15%, min 3)
507        assert!(path.len() >= config.steps);
508    }
509
510    #[test]
511    fn test_generate_path_no_huge_jumps() {
512        let from = Point::new(0.0, 0.0);
513        let to = Point::new(300.0, 300.0);
514        let config = SmartMouseConfig {
515            steps: 50,
516            overshoot: 0.0,
517            jitter: 0.0,
518            ..Default::default()
519        };
520
521        let path = generate_path(from, to, &config);
522
523        let mut prev = from;
524        let max_distance = (300.0_f64 * 300.0 + 300.0 * 300.0).sqrt(); // total distance
525
526        for step in &path {
527            let dx = step.point.x - prev.x;
528            let dy = step.point.y - prev.y;
529            let step_dist = (dx * dx + dy * dy).sqrt();
530            // No single step should jump more than half the total distance
531            assert!(
532                step_dist < max_distance * 0.6,
533                "step jumped {} pixels (max total: {})",
534                step_dist,
535                max_distance
536            );
537            prev = step.point;
538        }
539    }
540
541    #[test]
542    fn test_smart_mouse_position_tracking() {
543        let mouse = SmartMouse::new();
544
545        assert_eq!(mouse.position(), Point::new(0.0, 0.0));
546
547        mouse.set_position(Point::new(100.0, 200.0));
548        assert_eq!(mouse.position(), Point::new(100.0, 200.0));
549    }
550
551    #[test]
552    fn test_smart_mouse_path_to_updates_position() {
553        let mouse = SmartMouse::new();
554        let target = Point::new(500.0, 300.0);
555
556        let path = mouse.path_to(target);
557        assert!(!path.is_empty());
558
559        // Position should now be at the target
560        assert_eq!(mouse.position(), target);
561    }
562
563    #[test]
564    fn test_smart_mouse_consecutive_paths() {
565        let mouse = SmartMouse::with_config(SmartMouseConfig {
566            overshoot: 0.0,
567            jitter: 0.0,
568            ..Default::default()
569        });
570
571        let target1 = Point::new(100.0, 100.0);
572        let path1 = mouse.path_to(target1);
573        assert!(!path1.is_empty());
574        assert_eq!(mouse.position(), target1);
575
576        let target2 = Point::new(400.0, 300.0);
577        let _path2 = mouse.path_to(target2);
578        assert_eq!(mouse.position(), target2);
579    }
580
581    #[test]
582    fn test_smart_mouse_same_position_no_move() {
583        let mouse = SmartMouse::new();
584        mouse.set_position(Point::new(100.0, 100.0));
585
586        let path = mouse.path_to(Point::new(100.0, 100.0));
587        // Zero distance should produce a single direct step
588        assert_eq!(path.len(), 1);
589    }
590
591    #[test]
592    fn test_smart_mouse_custom_config() {
593        let config = SmartMouseConfig {
594            steps: 10,
595            overshoot: 0.0,
596            jitter: 0.0,
597            step_delay_ms: 16,
598            easing: false,
599            auto_size: false,
600            ..Default::default()
601        };
602
603        let mouse = SmartMouse::with_config(config.clone());
604        let path = mouse.path_to(Point::new(200.0, 200.0));
605
606        assert_eq!(path.len(), config.steps);
607    }
608
609    #[test]
610    fn test_movement_delays_are_reasonable() {
611        let config = SmartMouseConfig {
612            step_delay_ms: 10,
613            ..Default::default()
614        };
615
616        let path = generate_path(Point::new(0.0, 0.0), Point::new(500.0, 500.0), &config);
617
618        for step in &path {
619            // Delays should be within 0-30ms range for a 10ms base
620            assert!(
621                step.delay.as_millis() <= 30,
622                "delay too large: {:?}",
623                step.delay
624            );
625        }
626    }
627
628    #[test]
629    fn test_default_config() {
630        let config = SmartMouseConfig::default();
631        assert_eq!(config.steps, 25);
632        assert!((config.overshoot - 0.15).abs() < 1e-10);
633        assert!((config.jitter - 1.5).abs() < 1e-10);
634        assert_eq!(config.step_delay_ms, 8);
635        assert!(config.easing);
636        assert!(config.auto_size);
637        assert_eq!(config.min_duration_ms, 100);
638        assert_eq!(config.max_duration_ms, 800);
639        assert_eq!(config.pre_click_dwell_ms, Some((40, 120)));
640    }
641
642    #[test]
643    fn test_auto_size_scales_with_distance() {
644        // Disable overshoot so the path length equals the auto-sized
645        // step count exactly — the comparison is what we're testing.
646        let config = SmartMouseConfig {
647            overshoot: 0.0,
648            jitter: 0.0,
649            ..Default::default()
650        };
651
652        let short = generate_path(Point::new(0.0, 0.0), Point::new(60.0, 0.0), &config);
653        let long = generate_path(Point::new(0.0, 0.0), Point::new(1500.0, 0.0), &config);
654
655        assert!(
656            long.len() > short.len(),
657            "auto_size should give longer moves more steps: short={}, long={}",
658            short.len(),
659            long.len()
660        );
661    }
662
663    #[test]
664    fn test_auto_size_clamps_step_count() {
665        let config = SmartMouseConfig {
666            overshoot: 0.0,
667            jitter: 0.0,
668            ..Default::default()
669        };
670
671        // Short non-trivial move clamps to MIN_AUTO_STEPS
672        let tiny = generate_path(Point::new(0.0, 0.0), Point::new(8.0, 0.0), &config);
673        assert!(
674            tiny.len() >= MIN_AUTO_STEPS,
675            "tiny move should hit min step floor, got {}",
676            tiny.len()
677        );
678
679        // Massive move clamps to MAX_AUTO_STEPS
680        let huge = generate_path(Point::new(0.0, 0.0), Point::new(5000.0, 5000.0), &config);
681        assert!(
682            huge.len() <= MAX_AUTO_STEPS,
683            "huge move should hit max step ceiling, got {}",
684            huge.len()
685        );
686    }
687
688    #[test]
689    fn test_auto_size_total_duration_within_bounds() {
690        let config = SmartMouseConfig {
691            overshoot: 0.0,
692            jitter: 0.0,
693            ..Default::default()
694        };
695
696        let path = generate_path(Point::new(0.0, 0.0), Point::new(800.0, 600.0), &config);
697        let total_ms: u128 = path.iter().map(|s| s.delay.as_millis()).sum();
698
699        // Per-step delay variance is ±30%, so total can drift either way.
700        // Allow generous slack on the upper bound; the point is that
701        // auto_size actually caps duration, not that it's exact.
702        assert!(
703            (total_ms as u64) <= config.max_duration_ms + 300,
704            "auto-sized total {} ms should stay near max_duration_ms ({})",
705            total_ms,
706            config.max_duration_ms
707        );
708    }
709
710    #[test]
711    fn test_overshoot_skipped_for_short_moves() {
712        // Distance 150px is below the 200px overshoot threshold — the
713        // path must NOT include overshoot+correction shape, even with
714        // overshoot enabled in config. With overshoot off, the loop
715        // produces exactly `steps` items.
716        let config = SmartMouseConfig {
717            steps: 10,
718            overshoot: 0.5,
719            jitter: 0.0,
720            easing: false,
721            auto_size: false,
722            ..Default::default()
723        };
724
725        let path = generate_path(Point::new(0.0, 0.0), Point::new(150.0, 0.0), &config);
726        assert_eq!(path.len(), config.steps);
727    }
728
729    #[test]
730    fn test_overshoot_engages_for_long_moves() {
731        // Engagement is about *shape*, not length: at moderate step
732        // counts, `main_steps (~85%) + correction (~15%, min 3)` rounds
733        // back to ~steps. Detect engagement by checking that some
734        // intermediate point passes the target along the move axis,
735        // since correction is what brings it back.
736        let config = SmartMouseConfig {
737            steps: 20,
738            overshoot: 0.5,
739            jitter: 0.0,
740            easing: false,
741            auto_size: false,
742            ..Default::default()
743        };
744
745        let target_x = 800.0;
746        let path = generate_path(Point::new(0.0, 0.0), Point::new(target_x, 0.0), &config);
747
748        let passed_target = path.iter().any(|s| s.point.x > target_x + 1.0);
749        assert!(
750            passed_target,
751            "long move with overshoot should pass the target before correcting back"
752        );
753
754        // And the final point still lands exactly on target.
755        let last = path.last().expect("non-empty path");
756        assert!((last.point.x - target_x).abs() < 1e-9);
757    }
758
759    #[test]
760    fn test_pre_click_dwell_in_range() {
761        let mouse = SmartMouse::with_config(SmartMouseConfig {
762            pre_click_dwell_ms: Some((50, 100)),
763            ..Default::default()
764        });
765
766        for _ in 0..100 {
767            let dwell = mouse.pre_click_dwell().expect("dwell enabled");
768            let ms = dwell.as_millis() as u64;
769            assert!(
770                (50..=100).contains(&ms),
771                "dwell out of [50,100] range: {} ms",
772                ms
773            );
774        }
775    }
776
777    #[test]
778    fn test_pre_click_dwell_disabled() {
779        let mouse = SmartMouse::with_config(SmartMouseConfig {
780            pre_click_dwell_ms: None,
781            ..Default::default()
782        });
783        assert!(mouse.pre_click_dwell().is_none());
784
785        let mouse = SmartMouse::with_config(SmartMouseConfig {
786            pre_click_dwell_ms: Some((0, 0)),
787            ..Default::default()
788        });
789        assert!(mouse.pre_click_dwell().is_none());
790    }
791
792    #[test]
793    fn test_pre_click_dwell_fixed_when_min_eq_max() {
794        let mouse = SmartMouse::with_config(SmartMouseConfig {
795            pre_click_dwell_ms: Some((75, 75)),
796            ..Default::default()
797        });
798        let dwell = mouse.pre_click_dwell().expect("dwell enabled");
799        assert_eq!(dwell.as_millis(), 75);
800    }
801}