Skip to main content

ftui_core/
hover_stabilizer.rs

1#![forbid(unsafe_code)]
2
3//! Hover jitter stabilization using CUSUM change-point detection.
4//!
5//! Eliminates hover flicker when the pointer jitters around widget boundaries
6//! while maintaining responsiveness for intentional target changes.
7//!
8//! # Algorithm
9//!
10//! The stabilizer uses a simplified CUSUM (Cumulative Sum) change-point detector:
11//!
12//! - Track signed distance `d_t` to current target boundary (positive = inside)
13//! - Compute cumulative sum: `S_t = max(0, S_{t-1} + d_t - k)` where `k` is drift allowance
14//! - Switch target only when `S_t > h` (threshold) indicating strong evidence of intent
15//!
16//! A hysteresis band around boundaries prevents oscillation from single-cell jitter.
17//!
18//! # Invariants
19//!
20//! 1. Hover target only changes when evidence exceeds threshold
21//! 2. Single-cell jitter sequences do not cause target flicker
22//! 3. Intentional crossing (steady motion) triggers switch within ~2 frames
23//! 4. No measurable overhead (<2%) on hit-test pipeline
24//!
25//! # Failure Modes
26//!
27//! - If hit-test returns None consistently, stabilizer holds last known target
28//! - If threshold is too high, responsiveness degrades (tune via config)
29//! - If drift allowance is too low, jitter causes accumulation (tune k parameter)
30//!
31//! # Evidence Ledger
32//!
33//! In debug mode, the stabilizer logs:
34//! - CUSUM score at each update
35//! - Hysteresis state (inside band vs. outside)
36//! - Target switch events with evidence values
37
38use web_time::{Duration, Instant};
39
40// ---------------------------------------------------------------------------
41// Configuration
42// ---------------------------------------------------------------------------
43
44/// Configuration for hover jitter stabilization.
45#[derive(Debug, Clone)]
46pub struct HoverStabilizerConfig {
47    /// CUSUM drift allowance `k`. Higher = more tolerant of boundary oscillation.
48    /// Default: 0.5 (normalized distance units)
49    pub drift_allowance: f32,
50
51    /// CUSUM detection threshold `h`. Switch target when cumulative score exceeds this.
52    /// Default: 2.0 (equivalent to ~2-3 frames of consistent crossing signal)
53    pub detection_threshold: f32,
54
55    /// Hysteresis band width in cells. Pointer must move this far past boundary
56    /// before the boundary crossing is considered definitive.
57    /// Default: 1 cell
58    pub hysteresis_cells: u16,
59
60    /// Decay rate for CUSUM score when pointer is inside current target.
61    /// Prevents lingering switch intent from stale history.
62    /// Default: 0.1 per frame
63    pub decay_rate: f32,
64
65    /// Maximum duration to hold a target when no hit updates arrive.
66    /// After this, target resets to None.
67    /// Default: 500ms
68    pub hold_timeout: Duration,
69}
70
71impl Default for HoverStabilizerConfig {
72    fn default() -> Self {
73        Self {
74            drift_allowance: 0.5,
75            detection_threshold: 2.0,
76            hysteresis_cells: 1,
77            decay_rate: 0.1,
78            hold_timeout: Duration::from_millis(500),
79        }
80    }
81}
82
83// ---------------------------------------------------------------------------
84// Candidate tracking
85// ---------------------------------------------------------------------------
86
87/// Tracks a potential new hover target and its CUSUM evidence.
88#[derive(Debug, Clone)]
89struct CandidateTarget {
90    /// The potential new target ID.
91    target_id: u64,
92    /// Cumulative sum evidence score.
93    cusum_score: f32,
94    /// Last position where this candidate was observed.
95    last_pos: (u16, u16),
96}
97
98// ---------------------------------------------------------------------------
99// HoverStabilizer
100// ---------------------------------------------------------------------------
101
102/// Stateful hover stabilizer that prevents jitter-induced target flicker.
103///
104/// Feed hit-test results via [`update`](HoverStabilizer::update) and read
105/// the stabilized target from [`current_target`](HoverStabilizer::current_target).
106#[derive(Debug)]
107pub struct HoverStabilizer {
108    config: HoverStabilizerConfig,
109
110    /// Current stabilized hover target (None = no hover).
111    current_target: Option<u64>,
112
113    /// Position when current target was established.
114    current_target_pos: Option<(u16, u16)>,
115
116    /// Timestamp of last update.
117    last_update: Option<Instant>,
118
119    /// Candidate target being evaluated for switch.
120    candidate: Option<CandidateTarget>,
121
122    /// Diagnostic: total switch events.
123    switches: u64,
124}
125
126impl HoverStabilizer {
127    /// Create a new hover stabilizer with the given configuration.
128    #[must_use]
129    pub fn new(config: HoverStabilizerConfig) -> Self {
130        Self {
131            config,
132            current_target: None,
133            current_target_pos: None,
134            last_update: None,
135            candidate: None,
136            switches: 0,
137        }
138    }
139
140    /// Update the stabilizer with a new hit-test result.
141    ///
142    /// # Arguments
143    ///
144    /// - `hit_target`: The raw hit-test target ID (None = no hit)
145    /// - `pos`: Current pointer position
146    /// - `now`: Current timestamp
147    ///
148    /// # Returns
149    ///
150    /// The stabilized hover target, which may differ from `hit_target` to
151    /// prevent jitter-induced flicker.
152    pub fn update(
153        &mut self,
154        hit_target: Option<u64>,
155        pos: (u16, u16),
156        now: Instant,
157    ) -> Option<u64> {
158        // Check for hold timeout
159        if let Some(last) = self.last_update
160            && now.duration_since(last) > self.config.hold_timeout
161        {
162            self.reset();
163        }
164        self.last_update = Some(now);
165
166        // No current target: adopt immediately
167        if self.current_target.is_none() {
168            if hit_target.is_some() {
169                self.current_target = hit_target;
170                self.current_target_pos = Some(pos);
171                self.switches += 1;
172            }
173            return self.current_target;
174        }
175
176        let current = self
177            .current_target
178            .expect("current_target guaranteed by is_none early return");
179
180        // Same target: decay any candidate and return stable
181        if hit_target == Some(current) {
182            self.decay_candidate();
183            self.current_target_pos = Some(pos);
184            return self.current_target;
185        }
186
187        // Different target (or None): evaluate with CUSUM
188        let candidate_id = hit_target.unwrap_or(u64::MAX); // Use sentinel for None
189
190        // Compute signed distance to current target position
191        let distance = self.compute_distance_signal(pos);
192
193        self.update_candidate(candidate_id, distance, pos);
194
195        // Check if candidate evidence exceeds threshold
196        if let Some(ref cand) = self.candidate
197            && cand.cusum_score >= self.config.detection_threshold
198            && self.past_hysteresis_band(pos)
199        {
200            // Switch target
201            self.current_target = if candidate_id == u64::MAX {
202                None
203            } else {
204                Some(candidate_id)
205            };
206            self.current_target_pos = Some(pos);
207            self.candidate = None;
208            self.switches += 1;
209        }
210
211        self.current_target
212    }
213
214    /// Get the current stabilized hover target.
215    #[inline]
216    #[must_use]
217    pub fn current_target(&self) -> Option<u64> {
218        self.current_target
219    }
220
221    /// Reset all state to initial.
222    pub fn reset(&mut self) {
223        self.current_target = None;
224        self.current_target_pos = None;
225        self.last_update = None;
226        self.candidate = None;
227    }
228
229    /// Get the number of target switches (diagnostic).
230    #[inline]
231    #[must_use]
232    pub fn switch_count(&self) -> u64 {
233        self.switches
234    }
235
236    /// Get a reference to the current configuration.
237    #[inline]
238    #[must_use]
239    pub fn config(&self) -> &HoverStabilizerConfig {
240        &self.config
241    }
242
243    /// Update the configuration.
244    pub fn set_config(&mut self, config: HoverStabilizerConfig) {
245        self.config = config;
246    }
247
248    // -----------------------------------------------------------------------
249    // Internal helpers
250    // -----------------------------------------------------------------------
251
252    /// Compute distance signal for CUSUM update.
253    ///
254    /// Returns positive value when moving away from current target (toward boundary exit),
255    /// negative when inside target area.
256    fn compute_distance_signal(&self, pos: (u16, u16)) -> f32 {
257        let Some(target_pos) = self.current_target_pos else {
258            return 1.0; // No reference point: signal exit
259        };
260
261        // Manhattan distance from target position
262        let dx = (pos.0 as i32 - target_pos.0 as i32).abs();
263        let dy = (pos.1 as i32 - target_pos.1 as i32).abs();
264        let manhattan = (dx + dy) as f32;
265
266        // Normalize by hysteresis band
267        let hysteresis = self.config.hysteresis_cells.max(1) as f32;
268
269        // Positive = outside hysteresis band (moving away)
270        // Negative = inside hysteresis band (stable)
271        (manhattan - hysteresis) / hysteresis
272    }
273
274    /// Update CUSUM for candidate target.
275    fn update_candidate(&mut self, candidate_id: u64, distance_signal: f32, pos: (u16, u16)) {
276        let k = self.config.drift_allowance;
277
278        match &mut self.candidate {
279            Some(cand) if cand.target_id == candidate_id => {
280                // Same candidate: accumulate evidence
281                // S_t = max(0, S_{t-1} + d_t - k)
282                cand.cusum_score = (cand.cusum_score + distance_signal - k).max(0.0);
283                cand.last_pos = pos;
284            }
285            _ => {
286                // New candidate: start fresh
287                let initial_score = (distance_signal - k).max(0.0);
288                self.candidate = Some(CandidateTarget {
289                    target_id: candidate_id,
290                    cusum_score: initial_score,
291                    last_pos: pos,
292                });
293            }
294        }
295    }
296
297    /// Decay candidate evidence when pointer returns to current target.
298    fn decay_candidate(&mut self) {
299        if let Some(ref mut cand) = self.candidate {
300            cand.cusum_score *= 1.0 - self.config.decay_rate;
301            if cand.cusum_score < 0.01 {
302                self.candidate = None;
303            }
304        }
305    }
306
307    /// Check if current position is past the hysteresis band.
308    fn past_hysteresis_band(&self, pos: (u16, u16)) -> bool {
309        let Some(target_pos) = self.current_target_pos else {
310            return true; // No reference: allow switch
311        };
312
313        let dx = (pos.0 as i32 - target_pos.0 as i32).unsigned_abs();
314        let dy = (pos.1 as i32 - target_pos.1 as i32).unsigned_abs();
315        let manhattan = dx + dy;
316
317        manhattan > u32::from(self.config.hysteresis_cells)
318    }
319}
320
321// ---------------------------------------------------------------------------
322// Tests
323// ---------------------------------------------------------------------------
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn now() -> Instant {
330        Instant::now()
331    }
332
333    fn stabilizer() -> HoverStabilizer {
334        HoverStabilizer::new(HoverStabilizerConfig::default())
335    }
336
337    // --- Basic functionality tests ---
338
339    #[test]
340    fn initial_state_is_none() {
341        let stab = stabilizer();
342        assert!(stab.current_target().is_none());
343        assert_eq!(stab.switch_count(), 0);
344    }
345
346    #[test]
347    fn first_hit_adopted_immediately() {
348        let mut stab = stabilizer();
349        let t = now();
350
351        let target = stab.update(Some(42), (10, 10), t);
352        assert_eq!(target, Some(42));
353        assert_eq!(stab.current_target(), Some(42));
354        assert_eq!(stab.switch_count(), 1);
355    }
356
357    #[test]
358    fn same_target_stays_stable() {
359        let mut stab = stabilizer();
360        let t = now();
361
362        stab.update(Some(42), (10, 10), t);
363        stab.update(Some(42), (10, 11), t);
364        stab.update(Some(42), (11, 10), t);
365
366        assert_eq!(stab.current_target(), Some(42));
367        assert_eq!(stab.switch_count(), 1); // Only initial adoption
368    }
369
370    #[test]
371    fn jitter_does_not_switch() {
372        let mut stab = stabilizer();
373        let t = now();
374
375        // Establish target
376        stab.update(Some(42), (10, 10), t);
377
378        // Jitter: alternate between targets at boundary
379        for i in 0..10 {
380            let target = if i % 2 == 0 { Some(99) } else { Some(42) };
381            stab.update(target, (10, 10 + (i % 2)), t);
382        }
383
384        // Should still be on original target due to CUSUM not accumulating
385        assert_eq!(stab.current_target(), Some(42));
386    }
387
388    #[test]
389    fn sustained_crossing_triggers_switch() {
390        let mut stab = stabilizer();
391        let t = now();
392
393        // Establish target at (10, 10)
394        stab.update(Some(42), (10, 10), t);
395
396        // Move steadily away to new target
397        // Need to exceed threshold + hysteresis
398        for i in 1..=5 {
399            stab.update(Some(99), (10, 10 + i * 2), t);
400        }
401
402        // Should have switched to new target
403        assert_eq!(stab.current_target(), Some(99));
404        assert!(stab.switch_count() >= 2);
405    }
406
407    #[test]
408    fn reset_clears_all_state() {
409        let mut stab = stabilizer();
410        let t = now();
411
412        stab.update(Some(42), (10, 10), t);
413        stab.reset();
414
415        assert!(stab.current_target().is_none());
416    }
417
418    // --- CUSUM algorithm tests ---
419
420    #[test]
421    fn cusum_accumulates_on_consistent_signal() {
422        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
423            detection_threshold: 3.0,
424            hysteresis_cells: 0, // Disable hysteresis for this test
425            ..Default::default()
426        });
427        let t = now();
428
429        // Establish target
430        stab.update(Some(42), (10, 10), t);
431
432        // Consistent move away
433        stab.update(Some(99), (15, 10), t);
434        stab.update(Some(99), (20, 10), t);
435        stab.update(Some(99), (25, 10), t);
436
437        // Should have accumulated enough to switch
438        assert_eq!(stab.current_target(), Some(99));
439    }
440
441    #[test]
442    fn cusum_resets_on_return() {
443        let mut stab = stabilizer();
444        let t = now();
445
446        stab.update(Some(42), (10, 10), t);
447
448        // Briefly move away
449        stab.update(Some(99), (12, 10), t);
450
451        // Return to original target
452        stab.update(Some(42), (10, 10), t);
453        stab.update(Some(42), (10, 10), t);
454        stab.update(Some(42), (10, 10), t);
455
456        // Should still be on original (candidate decayed)
457        assert_eq!(stab.current_target(), Some(42));
458    }
459
460    // --- Hysteresis tests ---
461
462    #[test]
463    fn hysteresis_prevents_boundary_oscillation() {
464        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
465            hysteresis_cells: 2,
466            detection_threshold: 0.5, // Low threshold to test hysteresis
467            ..Default::default()
468        });
469        let t = now();
470
471        stab.update(Some(42), (10, 10), t);
472
473        // Move just past boundary but within hysteresis
474        stab.update(Some(99), (11, 10), t);
475        assert_eq!(stab.current_target(), Some(42));
476
477        // Move beyond hysteresis band (>2 cells)
478        stab.update(Some(99), (13, 10), t);
479        stab.update(Some(99), (14, 10), t);
480        stab.update(Some(99), (15, 10), t);
481
482        // Now should switch
483        assert_eq!(stab.current_target(), Some(99));
484    }
485
486    // --- Timeout tests ---
487
488    #[test]
489    fn timeout_resets_target() {
490        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
491            hold_timeout: Duration::from_millis(100),
492            ..Default::default()
493        });
494        let t = now();
495
496        stab.update(Some(42), (10, 10), t);
497        assert_eq!(stab.current_target(), Some(42));
498
499        // Update after timeout
500        let later = t + Duration::from_millis(200);
501        stab.update(Some(99), (20, 20), later);
502
503        // Should have reset and adopted new target
504        assert_eq!(stab.current_target(), Some(99));
505    }
506
507    // --- None target tests ---
508
509    #[test]
510    fn transition_to_none_with_evidence() {
511        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
512            hysteresis_cells: 0,
513            detection_threshold: 1.0,
514            ..Default::default()
515        });
516        let t = now();
517
518        stab.update(Some(42), (10, 10), t);
519
520        // Move away with no target
521        for i in 1..=5 {
522            stab.update(None, (10 + i * 3, 10), t);
523        }
524
525        // Should eventually transition to None
526        assert!(stab.current_target().is_none());
527    }
528
529    // --- Property tests ---
530
531    #[test]
532    fn jitter_stability_rate() {
533        let mut stab = stabilizer();
534        let t = now();
535
536        stab.update(Some(42), (10, 10), t);
537
538        // 100 jitter oscillations
539        let mut stable_count = 0;
540        for i in 0..100 {
541            let target = if i % 2 == 0 { Some(99) } else { Some(42) };
542            stab.update(target, (10, 10), t);
543            if stab.current_target() == Some(42) {
544                stable_count += 1;
545            }
546        }
547
548        // Should maintain >99% stability under jitter
549        assert!(stable_count >= 99, "Stable count: {}", stable_count);
550    }
551
552    #[test]
553    fn crossing_detection_latency() {
554        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
555            hysteresis_cells: 1,
556            detection_threshold: 1.5,
557            drift_allowance: 0.3,
558            ..Default::default()
559        });
560        let t = now();
561
562        stab.update(Some(42), (10, 10), t);
563
564        // Count frames until switch during steady motion
565        let mut frames = 0;
566        for i in 1..=10 {
567            stab.update(Some(99), (10, 10 + i * 2), t);
568            frames += 1;
569            if stab.current_target() == Some(99) {
570                break;
571            }
572        }
573
574        // Should switch within 3 frames
575        assert!(frames <= 3, "Switch took {} frames", frames);
576    }
577
578    // --- Config tests ---
579
580    #[test]
581    fn config_getter_and_setter() {
582        let mut stab = stabilizer();
583
584        assert_eq!(stab.config().detection_threshold, 2.0);
585
586        stab.set_config(HoverStabilizerConfig {
587            detection_threshold: 5.0,
588            ..Default::default()
589        });
590
591        assert_eq!(stab.config().detection_threshold, 5.0);
592    }
593
594    #[test]
595    fn default_config_values() {
596        let config = HoverStabilizerConfig::default();
597        assert_eq!(config.drift_allowance, 0.5);
598        assert_eq!(config.detection_threshold, 2.0);
599        assert_eq!(config.hysteresis_cells, 1);
600        assert_eq!(config.decay_rate, 0.1);
601        assert_eq!(config.hold_timeout, Duration::from_millis(500));
602    }
603
604    // --- Debug format test ---
605
606    #[test]
607    fn debug_format() {
608        let stab = stabilizer();
609        let dbg = format!("{:?}", stab);
610        assert!(dbg.contains("HoverStabilizer"));
611    }
612
613    #[test]
614    fn switch_count_preserved_after_reset() {
615        let mut stab = stabilizer();
616        let t = now();
617
618        stab.update(Some(42), (10, 10), t);
619        assert_eq!(stab.switch_count(), 1);
620
621        stab.reset();
622        // switch count is NOT cleared by reset (it's a diagnostic counter)
623        assert_eq!(stab.switch_count(), 1);
624        assert!(stab.current_target().is_none());
625    }
626
627    #[test]
628    fn none_hit_when_no_current_target() {
629        let mut stab = stabilizer();
630        let t = now();
631
632        // Update with None when no target established
633        let target = stab.update(None, (10, 10), t);
634        assert_eq!(target, None);
635        assert_eq!(stab.switch_count(), 0);
636    }
637
638    #[test]
639    fn config_clone() {
640        let config = HoverStabilizerConfig::default();
641        let cloned = config.clone();
642        assert_eq!(cloned.drift_allowance, config.drift_allowance);
643        assert_eq!(cloned.detection_threshold, config.detection_threshold);
644        assert_eq!(cloned.hysteresis_cells, config.hysteresis_cells);
645    }
646
647    // ── Edge-case: Config ────────────────────────────────────────────
648
649    #[test]
650    fn config_debug_format() {
651        let config = HoverStabilizerConfig::default();
652        let dbg = format!("{:?}", config);
653        assert!(dbg.contains("HoverStabilizerConfig"));
654        assert!(dbg.contains("drift_allowance"));
655    }
656
657    #[test]
658    fn config_zero_hysteresis() {
659        let config = HoverStabilizerConfig {
660            hysteresis_cells: 0,
661            ..Default::default()
662        };
663        assert_eq!(config.hysteresis_cells, 0);
664    }
665
666    #[test]
667    fn config_zero_hold_timeout() {
668        let config = HoverStabilizerConfig {
669            hold_timeout: Duration::ZERO,
670            ..Default::default()
671        };
672        assert_eq!(config.hold_timeout, Duration::ZERO);
673    }
674
675    // ── Edge-case: HoverStabilizer creation ──────────────────────────
676
677    #[test]
678    fn new_with_custom_config() {
679        let config = HoverStabilizerConfig {
680            drift_allowance: 1.0,
681            detection_threshold: 10.0,
682            hysteresis_cells: 5,
683            decay_rate: 0.5,
684            hold_timeout: Duration::from_secs(2),
685        };
686        let stab = HoverStabilizer::new(config);
687        assert!(stab.current_target().is_none());
688        assert_eq!(stab.switch_count(), 0);
689        assert_eq!(stab.config().drift_allowance, 1.0);
690        assert_eq!(stab.config().detection_threshold, 10.0);
691        assert_eq!(stab.config().hysteresis_cells, 5);
692    }
693
694    // ── Edge-case: Reset behavior ────────────────────────────────────
695
696    #[test]
697    fn reset_then_adopt_new_target() {
698        let mut stab = stabilizer();
699        let t = now();
700
701        stab.update(Some(42), (10, 10), t);
702        assert_eq!(stab.current_target(), Some(42));
703
704        stab.reset();
705        assert!(stab.current_target().is_none());
706
707        // New target should be adopted immediately after reset
708        let target = stab.update(Some(99), (20, 20), t);
709        assert_eq!(target, Some(99));
710        assert_eq!(stab.switch_count(), 2); // original + re-adopt
711    }
712
713    #[test]
714    fn multiple_resets_are_idempotent() {
715        let mut stab = stabilizer();
716        let t = now();
717
718        stab.update(Some(42), (10, 10), t);
719        stab.reset();
720        stab.reset();
721        stab.reset();
722
723        assert!(stab.current_target().is_none());
724        // switch count preserved across all resets
725        assert_eq!(stab.switch_count(), 1);
726    }
727
728    // ── Edge-case: Timeout boundary ──────────────────────────────────
729
730    #[test]
731    fn exactly_at_timeout_does_not_reset() {
732        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
733            hold_timeout: Duration::from_millis(100),
734            ..Default::default()
735        });
736        let t = now();
737
738        stab.update(Some(42), (10, 10), t);
739
740        // Update at exactly the timeout duration (not beyond)
741        let at_boundary = t + Duration::from_millis(100);
742        let target = stab.update(Some(42), (10, 10), at_boundary);
743        // At boundary (duration_since == hold_timeout), > check means no reset
744        assert_eq!(target, Some(42));
745    }
746
747    #[test]
748    fn just_past_timeout_resets() {
749        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
750            hold_timeout: Duration::from_millis(100),
751            ..Default::default()
752        });
753        let t = now();
754
755        stab.update(Some(42), (10, 10), t);
756
757        // Update just past the timeout
758        let past = t + Duration::from_millis(101);
759        let target = stab.update(Some(99), (20, 20), past);
760        // Should have reset and adopted new target
761        assert_eq!(target, Some(99));
762    }
763
764    #[test]
765    fn timeout_then_none_hit() {
766        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
767            hold_timeout: Duration::from_millis(50),
768            ..Default::default()
769        });
770        let t = now();
771
772        stab.update(Some(42), (10, 10), t);
773
774        let later = t + Duration::from_millis(100);
775        let target = stab.update(None, (10, 10), later);
776        // Should reset and then None hit means no target
777        assert_eq!(target, None);
778    }
779
780    // ── Edge-case: Position boundaries ───────────────────────────────
781
782    #[test]
783    fn position_at_origin() {
784        let mut stab = stabilizer();
785        let t = now();
786
787        let target = stab.update(Some(1), (0, 0), t);
788        assert_eq!(target, Some(1));
789    }
790
791    #[test]
792    fn position_at_u16_max() {
793        let mut stab = stabilizer();
794        let t = now();
795
796        let target = stab.update(Some(1), (u16::MAX, u16::MAX), t);
797        assert_eq!(target, Some(1));
798        assert_eq!(stab.current_target(), Some(1));
799    }
800
801    #[test]
802    fn same_position_different_targets_no_switch() {
803        let mut stab = stabilizer();
804        let t = now();
805
806        stab.update(Some(42), (10, 10), t);
807
808        // Same position, different target — distance signal is 0
809        // So CUSUM score stays low (0 - k < 0 → clamped to 0)
810        for _ in 0..20 {
811            stab.update(Some(99), (10, 10), t);
812        }
813
814        // Within hysteresis band (distance = 0), should not switch
815        assert_eq!(stab.current_target(), Some(42));
816    }
817
818    // ── Edge-case: Multiple sequential targets ───────────────────────
819
820    #[test]
821    fn candidate_resets_on_new_third_target() {
822        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
823            hysteresis_cells: 0,
824            detection_threshold: 5.0, // High threshold
825            ..Default::default()
826        });
827        let t = now();
828
829        stab.update(Some(42), (10, 10), t);
830
831        // Start building evidence for target 99
832        stab.update(Some(99), (15, 10), t);
833        stab.update(Some(99), (20, 10), t);
834
835        // Switch to target 77 (candidate should reset for new target)
836        stab.update(Some(77), (25, 10), t);
837        stab.update(Some(77), (30, 10), t);
838        stab.update(Some(77), (35, 10), t);
839        stab.update(Some(77), (40, 10), t);
840        stab.update(Some(77), (45, 10), t);
841
842        // Should eventually switch to 77 (not 99)
843        assert!(
844            stab.current_target() == Some(77) || stab.current_target() == Some(42),
845            "target should be 77 or still 42, got {:?}",
846            stab.current_target()
847        );
848    }
849
850    // ── Edge-case: High/low detection threshold ──────────────────────
851
852    #[test]
853    fn very_high_threshold_prevents_switching() {
854        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
855            detection_threshold: 100_000.0,
856            hysteresis_cells: 0,
857            ..Default::default()
858        });
859        let t = now();
860
861        stab.update(Some(42), (10, 10), t);
862
863        // Large movements but CUSUM accumulation (~2070) << threshold (100_000)
864        for i in 1..=20 {
865            stab.update(Some(99), (10, 10 + i * 10), t);
866        }
867
868        // Still on original target
869        assert_eq!(stab.current_target(), Some(42));
870    }
871
872    #[test]
873    fn very_low_threshold_allows_quick_switch() {
874        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
875            detection_threshold: 0.01,
876            hysteresis_cells: 0,
877            drift_allowance: 0.0,
878            ..Default::default()
879        });
880        let t = now();
881
882        stab.update(Some(42), (10, 10), t);
883
884        // Single frame at distance should be enough
885        stab.update(Some(99), (15, 10), t);
886
887        assert_eq!(stab.current_target(), Some(99));
888        assert_eq!(stab.switch_count(), 2);
889    }
890
891    // ── Edge-case: Decay rate extremes ───────────────────────────────
892
893    #[test]
894    fn decay_rate_zero_no_decay() {
895        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
896            decay_rate: 0.0,
897            detection_threshold: 100.0, // Very high to prevent immediate switch
898            hysteresis_cells: 0,
899            ..Default::default()
900        });
901        let t = now();
902
903        stab.update(Some(42), (10, 10), t);
904
905        // Build some evidence
906        stab.update(Some(99), (20, 10), t);
907        stab.update(Some(99), (30, 10), t);
908
909        // Return to original — with 0 decay, candidate stays
910        stab.update(Some(42), (10, 10), t);
911        stab.update(Some(42), (10, 10), t);
912
913        // Candidate should still have evidence (no decay)
914        // We can't directly inspect candidate, but we know the algorithm
915        // Just verify it doesn't crash
916        assert_eq!(stab.current_target(), Some(42));
917    }
918
919    #[test]
920    fn decay_rate_one_instant_decay() {
921        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
922            decay_rate: 1.0,
923            detection_threshold: 2.0,
924            hysteresis_cells: 0,
925            ..Default::default()
926        });
927        let t = now();
928
929        stab.update(Some(42), (10, 10), t);
930
931        // Build evidence then return
932        stab.update(Some(99), (20, 10), t);
933        stab.update(Some(42), (10, 10), t); // decay_rate=1.0 → score*0.0=0 → candidate cleared
934
935        // Continue with new target — should need fresh evidence
936        for i in 1..=5 {
937            stab.update(Some(99), (10, 10 + i * 5), t);
938        }
939
940        // Should switch eventually (fresh evidence not affected by old candidate)
941        // Just verify no crash and reasonable behavior
942        let target = stab.current_target();
943        assert!(target == Some(42) || target == Some(99));
944    }
945
946    // ── Edge-case: Zero drift allowance ──────────────────────────────
947
948    #[test]
949    fn zero_drift_allowance_fast_accumulation() {
950        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
951            drift_allowance: 0.0,
952            detection_threshold: 1.0,
953            hysteresis_cells: 0,
954            ..Default::default()
955        });
956        let t = now();
957
958        stab.update(Some(42), (10, 10), t);
959
960        // With k=0, any positive signal accumulates directly
961        stab.update(Some(99), (15, 10), t);
962
963        // Should switch quickly since no drift subtracted
964        assert_eq!(stab.current_target(), Some(99));
965    }
966
967    // ── Edge-case: Target ID boundaries ──────────────────────────────
968
969    #[test]
970    fn target_id_zero() {
971        let mut stab = stabilizer();
972        let t = now();
973
974        let target = stab.update(Some(0), (10, 10), t);
975        assert_eq!(target, Some(0));
976        assert_eq!(stab.current_target(), Some(0));
977    }
978
979    #[test]
980    fn target_id_max_u64() {
981        let mut stab = stabilizer();
982        let t = now();
983
984        // u64::MAX is used internally as sentinel for None, but as a real target ID
985        // it should be handled. Note: this tests the sentinel collision edge case.
986        let target = stab.update(Some(u64::MAX), (10, 10), t);
987        assert_eq!(target, Some(u64::MAX));
988    }
989
990    // ── Edge-case: Switch count tracking ─────────────────────────────
991
992    #[test]
993    fn switch_count_increments_across_multiple_switches() {
994        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
995            detection_threshold: 0.01,
996            hysteresis_cells: 0,
997            drift_allowance: 0.0,
998            ..Default::default()
999        });
1000        let t = now();
1001
1002        stab.update(Some(1), (10, 10), t);
1003        assert_eq!(stab.switch_count(), 1);
1004
1005        stab.update(Some(2), (30, 10), t);
1006        assert_eq!(stab.current_target(), Some(2));
1007        assert_eq!(stab.switch_count(), 2);
1008
1009        stab.update(Some(3), (60, 10), t);
1010        assert_eq!(stab.current_target(), Some(3));
1011        assert_eq!(stab.switch_count(), 3);
1012    }
1013
1014    // ── Edge-case: set_config during active tracking ─────────────────
1015
1016    #[test]
1017    fn set_config_preserves_state() {
1018        let mut stab = stabilizer();
1019        let t = now();
1020
1021        stab.update(Some(42), (10, 10), t);
1022        assert_eq!(stab.current_target(), Some(42));
1023
1024        // Change config while tracking
1025        stab.set_config(HoverStabilizerConfig {
1026            detection_threshold: 100.0,
1027            ..Default::default()
1028        });
1029
1030        // Current target should be preserved
1031        assert_eq!(stab.current_target(), Some(42));
1032        assert_eq!(stab.config().detection_threshold, 100.0);
1033    }
1034
1035    // ── Edge-case: Large hysteresis band ─────────────────────────────
1036
1037    #[test]
1038    fn large_hysteresis_requires_big_movement() {
1039        let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
1040            hysteresis_cells: 100,
1041            detection_threshold: 0.01,
1042            drift_allowance: 0.0,
1043            ..Default::default()
1044        });
1045        let t = now();
1046
1047        stab.update(Some(42), (100, 100), t);
1048
1049        // Move 50 cells — within hysteresis band of 100
1050        stab.update(Some(99), (150, 100), t);
1051        stab.update(Some(99), (150, 100), t);
1052        assert_eq!(stab.current_target(), Some(42)); // Still within band
1053
1054        // Move 200 cells — way past hysteresis
1055        for i in 1..=5 {
1056            stab.update(Some(99), (100 + (i * 50), 100), t);
1057        }
1058        // Should eventually switch
1059        assert_eq!(stab.current_target(), Some(99));
1060    }
1061}