Skip to main content

ftui_render/
spatial_hit_index.rs

1#![forbid(unsafe_code)]
2
3//! Spatial hit-test index with z-order support and dirty-rect caching.
4//!
5//! Provides O(1) average-case hit-test queries for thousands of widgets
6//! by using uniform grid bucketing with z-order tracking.
7//!
8//! # Design
9//!
10//! Uses a hybrid approach:
11//! - **Uniform grid**: Screen divided into cells (default 8x8 pixels each)
12//! - **Bucket lists**: Each grid cell stores widget IDs that overlap it
13//! - **Z-order tracking**: Widgets have explicit z-order; topmost wins on overlap
14//! - **Dirty-rect cache**: Last hover result cached; invalidated on dirty regions
15//!
16//! # Invariants
17//!
18//! 1. Hit-test always returns topmost widget (highest z) at query point
19//! 2. Ties broken by registration order (later = on top)
20//! 3. Dirty regions force recomputation of affected buckets only
21//! 4. No allocations on steady-state hit-test queries
22//!
23//! # Failure Modes
24//!
25//! - If bucket overflow occurs, falls back to linear scan (logged)
26//! - If z-order gaps are large, memory is proportional to max z not widget count
27//!   (mitigated by z-rank normalization on rebuild)
28
29use crate::frame::{HitData, HitId, HitRegion};
30use ahash::AHashMap;
31use ftui_core::geometry::Rect;
32
33// ---------------------------------------------------------------------------
34// Configuration
35// ---------------------------------------------------------------------------
36
37/// Configuration for the spatial hit index.
38#[derive(Debug, Clone)]
39pub struct SpatialHitConfig {
40    /// Grid cell size in terminal cells (default: 8).
41    /// Smaller = more memory, faster queries. Larger = less memory, slower queries.
42    pub cell_size: u16,
43
44    /// Maximum widgets per bucket before logging warning (default: 64).
45    pub bucket_warn_threshold: usize,
46
47    /// Enable cache hit tracking for diagnostics (default: false).
48    pub track_cache_stats: bool,
49}
50
51impl Default for SpatialHitConfig {
52    fn default() -> Self {
53        Self {
54            cell_size: 8,
55            bucket_warn_threshold: 64,
56            track_cache_stats: false,
57        }
58    }
59}
60
61// ---------------------------------------------------------------------------
62// Widget hitbox entry
63// ---------------------------------------------------------------------------
64
65/// A registered widget's hit information.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct HitEntry {
68    /// Widget identifier.
69    pub id: HitId,
70    /// Bounding rectangle.
71    pub rect: Rect,
72    /// Region type for hit callbacks.
73    pub region: HitRegion,
74    /// User data attached to this hit.
75    pub data: HitData,
76    /// Z-order layer (higher = on top).
77    pub z_order: u16,
78    /// Registration order for tie-breaking.
79    order: u32,
80}
81
82impl HitEntry {
83    /// Create a new hit entry.
84    pub fn new(
85        id: HitId,
86        rect: Rect,
87        region: HitRegion,
88        data: HitData,
89        z_order: u16,
90        order: u32,
91    ) -> Self {
92        Self {
93            id,
94            rect,
95            region,
96            data,
97            z_order,
98            order,
99        }
100    }
101
102    /// Check if point (x, y) is inside this entry's rect.
103    #[inline]
104    pub fn contains(&self, x: u16, y: u16) -> bool {
105        x >= self.rect.x
106            && x < self.rect.x.saturating_add(self.rect.width)
107            && y >= self.rect.y
108            && y < self.rect.y.saturating_add(self.rect.height)
109    }
110
111    /// Compare for z-order (higher z wins, then later order wins).
112    #[inline]
113    fn cmp_z_order(&self, other: &Self) -> std::cmp::Ordering {
114        match self.z_order.cmp(&other.z_order) {
115            std::cmp::Ordering::Equal => self.order.cmp(&other.order),
116            ord => ord,
117        }
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Bucket for grid cell
123// ---------------------------------------------------------------------------
124
125/// Bucket storing widget indices for a grid cell.
126#[derive(Debug, Clone, Default)]
127struct Bucket {
128    /// Indices into the entries array.
129    entries: Vec<u32>,
130}
131
132impl Bucket {
133    /// Add an entry index to this bucket.
134    #[inline]
135    fn push(&mut self, entry_idx: u32) {
136        self.entries.push(entry_idx);
137    }
138
139    /// Clear the bucket.
140    #[inline]
141    fn clear(&mut self) {
142        self.entries.clear();
143    }
144}
145
146// ---------------------------------------------------------------------------
147// Cache for hover results
148// ---------------------------------------------------------------------------
149
150/// Cached hover result to avoid recomputation.
151#[derive(Debug, Clone, Copy, Default)]
152struct HoverCache {
153    /// Last queried position.
154    pos: (u16, u16),
155    /// Cached result (entry index or None).
156    result: Option<u32>,
157    /// Whether cache is valid.
158    valid: bool,
159}
160
161// ---------------------------------------------------------------------------
162// Dirty region tracking
163// ---------------------------------------------------------------------------
164
165/// Dirty region tracker for incremental updates.
166#[derive(Debug, Clone, Default)]
167struct DirtyTracker {
168    /// Dirty rectangles pending processing.
169    dirty_rects: Vec<Rect>,
170    /// Whether entire index needs rebuild.
171    full_rebuild: bool,
172}
173
174impl DirtyTracker {
175    /// Mark a rectangle as dirty.
176    fn mark_dirty(&mut self, rect: Rect) {
177        if !self.full_rebuild {
178            self.dirty_rects.push(rect);
179        }
180    }
181
182    /// Mark entire index as dirty.
183    fn mark_full_rebuild(&mut self) {
184        self.full_rebuild = true;
185        self.dirty_rects.clear();
186    }
187
188    /// Clear dirty state after processing.
189    fn clear(&mut self) {
190        self.dirty_rects.clear();
191        self.full_rebuild = false;
192    }
193
194    /// Check if position overlaps any dirty region.
195    fn is_dirty(&self, x: u16, y: u16) -> bool {
196        if self.full_rebuild {
197            return true;
198        }
199        for rect in &self.dirty_rects {
200            if x >= rect.x
201                && x < rect.x.saturating_add(rect.width)
202                && y >= rect.y
203                && y < rect.y.saturating_add(rect.height)
204            {
205                return true;
206            }
207        }
208        false
209    }
210}
211
212// ---------------------------------------------------------------------------
213// Cache statistics
214// ---------------------------------------------------------------------------
215
216/// Diagnostic statistics for cache performance.
217#[derive(Debug, Clone, Copy, Default)]
218pub struct CacheStats {
219    /// Number of cache hits.
220    pub hits: u64,
221    /// Number of cache misses.
222    pub misses: u64,
223    /// Number of full index rebuilds.
224    pub rebuilds: u64,
225}
226
227impl CacheStats {
228    /// Cache hit rate as percentage.
229    #[must_use]
230    pub fn hit_rate(&self) -> f32 {
231        let total = self.hits + self.misses;
232        if total == 0 {
233            0.0
234        } else {
235            (self.hits as f32 / total as f32) * 100.0
236        }
237    }
238}
239
240// ---------------------------------------------------------------------------
241// SpatialHitIndex
242// ---------------------------------------------------------------------------
243
244/// Spatial index for efficient hit-testing with z-order support.
245///
246/// Provides O(1) average-case queries by bucketing widgets into a uniform grid.
247/// Supports dirty-rect caching to avoid recomputation of unchanged regions.
248#[derive(Debug)]
249pub struct SpatialHitIndex {
250    config: SpatialHitConfig,
251
252    /// Screen dimensions.
253    width: u16,
254    height: u16,
255
256    /// Grid dimensions (in buckets).
257    grid_width: u16,
258    grid_height: u16,
259
260    /// All registered hit entries.
261    entries: Vec<HitEntry>,
262
263    /// Spatial grid buckets (row-major).
264    buckets: Vec<Bucket>,
265
266    /// Registration counter for tie-breaking.
267    next_order: u32,
268
269    /// Hover cache.
270    cache: HoverCache,
271
272    /// Dirty region tracker.
273    dirty: DirtyTracker,
274
275    /// Diagnostic statistics.
276    stats: CacheStats,
277
278    /// Fast lookup from HitId to entry index.
279    id_to_entry: AHashMap<HitId, u32>,
280}
281
282impl SpatialHitIndex {
283    /// Create a new spatial hit index for the given screen dimensions.
284    pub fn new(width: u16, height: u16, config: SpatialHitConfig) -> Self {
285        let cell_size = config.cell_size.max(1);
286        let grid_width = (width.saturating_add(cell_size - 1)) / cell_size;
287        let grid_height = (height.saturating_add(cell_size - 1)) / cell_size;
288        let bucket_count = grid_width as usize * grid_height as usize;
289
290        Self {
291            config,
292            width,
293            height,
294            grid_width,
295            grid_height,
296            entries: Vec::with_capacity(256),
297            buckets: vec![Bucket::default(); bucket_count],
298            next_order: 0,
299            cache: HoverCache::default(),
300            dirty: DirtyTracker::default(),
301            stats: CacheStats::default(),
302            id_to_entry: AHashMap::with_capacity(256),
303        }
304    }
305
306    /// Create with default configuration.
307    pub fn with_defaults(width: u16, height: u16) -> Self {
308        Self::new(width, height, SpatialHitConfig::default())
309    }
310
311    /// Register a widget hitbox.
312    ///
313    /// # Arguments
314    ///
315    /// - `id`: Unique widget identifier
316    /// - `rect`: Bounding rectangle
317    /// - `region`: Hit region type
318    /// - `data`: User data
319    /// - `z_order`: Z-order layer (higher = on top)
320    pub fn register(
321        &mut self,
322        id: HitId,
323        rect: Rect,
324        region: HitRegion,
325        data: HitData,
326        z_order: u16,
327    ) {
328        // Create entry
329        let entry_idx = self.entries.len() as u32;
330        let entry = HitEntry::new(id, rect, region, data, z_order, self.next_order);
331        self.next_order = self.next_order.wrapping_add(1);
332
333        self.entries.push(entry);
334        self.id_to_entry.insert(id, entry_idx);
335
336        // Add to relevant buckets
337        self.add_to_buckets(entry_idx, rect);
338
339        // Invalidate cache for this region
340        self.dirty.mark_dirty(rect);
341        if self.cache.valid && self.dirty.is_dirty(self.cache.pos.0, self.cache.pos.1) {
342            self.cache.valid = false;
343        }
344    }
345
346    /// Register with default z-order (0).
347    pub fn register_simple(&mut self, id: HitId, rect: Rect, region: HitRegion, data: HitData) {
348        self.register(id, rect, region, data, 0);
349    }
350
351    /// Update an existing widget's hitbox.
352    ///
353    /// Returns `true` if widget was found and updated.
354    pub fn update(&mut self, id: HitId, new_rect: Rect) -> bool {
355        let Some(&entry_idx) = self.id_to_entry.get(&id) else {
356            return false;
357        };
358
359        let old_rect = self.entries[entry_idx as usize].rect;
360
361        // Mark both old and new regions as dirty
362        self.dirty.mark_dirty(old_rect);
363        self.dirty.mark_dirty(new_rect);
364
365        // Update entry
366        self.entries[entry_idx as usize].rect = new_rect;
367
368        // Rebuild buckets for affected regions
369        // For simplicity, we do a full rebuild. Production could do incremental.
370        self.rebuild_buckets();
371
372        // Invalidate cache
373        self.cache.valid = false;
374
375        true
376    }
377
378    /// Remove a widget from the index.
379    ///
380    /// Returns `true` if widget was found and removed.
381    pub fn remove(&mut self, id: HitId) -> bool {
382        let Some(&entry_idx) = self.id_to_entry.get(&id) else {
383            return false;
384        };
385
386        let rect = self.entries[entry_idx as usize].rect;
387        self.dirty.mark_dirty(rect);
388
389        // Mark entry as removed (set id to default)
390        self.entries[entry_idx as usize].id = HitId::default();
391        self.id_to_entry.remove(&id);
392
393        // Rebuild buckets
394        self.rebuild_buckets();
395        self.cache.valid = false;
396
397        true
398    }
399
400    /// Hit test at the given position.
401    ///
402    /// Returns the topmost (highest z-order) widget at (x, y), if any.
403    ///
404    /// # Performance
405    ///
406    /// - O(1) average case with cache hit
407    /// - O(k) where k = widgets overlapping the bucket cell
408    #[must_use]
409    pub fn hit_test(&mut self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
410        // Bounds check
411        if x >= self.width || y >= self.height {
412            return None;
413        }
414
415        // Check cache
416        if self.cache.valid && self.cache.pos == (x, y) {
417            if self.config.track_cache_stats {
418                self.stats.hits += 1;
419            }
420            return self.cache.result.map(|idx| {
421                let e = &self.entries[idx as usize];
422                (e.id, e.region, e.data)
423            });
424        }
425
426        if self.config.track_cache_stats {
427            self.stats.misses += 1;
428        }
429
430        // Find bucket
431        let bucket_idx = self.bucket_index(x, y);
432        let bucket = &self.buckets[bucket_idx];
433
434        // Find topmost widget at (x, y)
435        let mut best: Option<&HitEntry> = None;
436        let mut best_idx: Option<u32> = None;
437
438        for &entry_idx in &bucket.entries {
439            let entry = &self.entries[entry_idx as usize];
440
441            // Skip removed entries
442            if entry.id == HitId::default() {
443                continue;
444            }
445
446            // Check if point is inside this entry
447            if entry.contains(x, y) {
448                // Compare z-order
449                match best {
450                    None => {
451                        best = Some(entry);
452                        best_idx = Some(entry_idx);
453                    }
454                    Some(current_best) if entry.cmp_z_order(current_best).is_gt() => {
455                        best = Some(entry);
456                        best_idx = Some(entry_idx);
457                    }
458                    _ => {}
459                }
460            }
461        }
462
463        // Update cache
464        self.cache.pos = (x, y);
465        self.cache.result = best_idx;
466        self.cache.valid = true;
467        // The cache now reflects the current index state, so prior dirties are irrelevant.
468        self.dirty.clear();
469
470        best.map(|e| (e.id, e.region, e.data))
471    }
472
473    /// Hit test without modifying cache (for read-only queries).
474    #[must_use]
475    pub fn hit_test_readonly(&self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
476        if x >= self.width || y >= self.height {
477            return None;
478        }
479
480        let bucket_idx = self.bucket_index(x, y);
481        let bucket = &self.buckets[bucket_idx];
482
483        let mut best: Option<&HitEntry> = None;
484
485        for &entry_idx in &bucket.entries {
486            let entry = &self.entries[entry_idx as usize];
487            if entry.id == HitId::default() {
488                continue;
489            }
490            if entry.contains(x, y) {
491                match best {
492                    None => best = Some(entry),
493                    Some(current_best) if entry.cmp_z_order(current_best).is_gt() => {
494                        best = Some(entry)
495                    }
496                    _ => {}
497                }
498            }
499        }
500
501        best.map(|e| (e.id, e.region, e.data))
502    }
503
504    /// Clear all entries and reset the index.
505    pub fn clear(&mut self) {
506        self.entries.clear();
507        self.id_to_entry.clear();
508        for bucket in &mut self.buckets {
509            bucket.clear();
510        }
511        self.next_order = 0;
512        self.cache.valid = false;
513        self.dirty.clear();
514    }
515
516    /// Get diagnostic statistics.
517    #[must_use]
518    pub fn stats(&self) -> CacheStats {
519        self.stats
520    }
521
522    /// Reset diagnostic statistics.
523    pub fn reset_stats(&mut self) {
524        self.stats = CacheStats::default();
525    }
526
527    /// Number of registered widgets.
528    #[inline]
529    #[must_use]
530    pub fn len(&self) -> usize {
531        self.id_to_entry.len()
532    }
533
534    /// Check if empty.
535    #[inline]
536    #[must_use]
537    pub fn is_empty(&self) -> bool {
538        self.id_to_entry.is_empty()
539    }
540
541    /// Invalidate cache for a specific region.
542    pub fn invalidate_region(&mut self, rect: Rect) {
543        self.dirty.mark_dirty(rect);
544        if self.cache.valid && self.dirty.is_dirty(self.cache.pos.0, self.cache.pos.1) {
545            self.cache.valid = false;
546        }
547    }
548
549    /// Force full cache invalidation.
550    pub fn invalidate_all(&mut self) {
551        self.cache.valid = false;
552        self.dirty.mark_full_rebuild();
553    }
554
555    // -----------------------------------------------------------------------
556    // Internal helpers
557    // -----------------------------------------------------------------------
558
559    /// Calculate bucket index for a point.
560    #[inline]
561    fn bucket_index(&self, x: u16, y: u16) -> usize {
562        let cell_size = self.config.cell_size;
563        let bx = x / cell_size;
564        let by = y / cell_size;
565        by as usize * self.grid_width as usize + bx as usize
566    }
567
568    /// Calculate bucket range for a rectangle.
569    fn bucket_range(&self, rect: Rect) -> (u16, u16, u16, u16) {
570        let cell_size = self.config.cell_size;
571        let bx_start = rect.x / cell_size;
572        let by_start = rect.y / cell_size;
573        let bx_end = rect.x.saturating_add(rect.width.saturating_sub(1)) / cell_size;
574        let by_end = rect.y.saturating_add(rect.height.saturating_sub(1)) / cell_size;
575        (
576            bx_start.min(self.grid_width.saturating_sub(1)),
577            by_start.min(self.grid_height.saturating_sub(1)),
578            bx_end.min(self.grid_width.saturating_sub(1)),
579            by_end.min(self.grid_height.saturating_sub(1)),
580        )
581    }
582
583    /// Add an entry to all buckets it overlaps.
584    fn add_to_buckets(&mut self, entry_idx: u32, rect: Rect) {
585        if rect.width == 0 || rect.height == 0 {
586            return;
587        }
588
589        let (bx_start, by_start, bx_end, by_end) = self.bucket_range(rect);
590
591        for by in by_start..=by_end {
592            for bx in bx_start..=bx_end {
593                let bucket_idx = by as usize * self.grid_width as usize + bx as usize;
594                if bucket_idx < self.buckets.len() {
595                    self.buckets[bucket_idx].push(entry_idx);
596
597                    // Warn if bucket is getting large
598                    if self.buckets[bucket_idx].entries.len() > self.config.bucket_warn_threshold {
599                        // In production, log this
600                    }
601                }
602            }
603        }
604    }
605
606    /// Rebuild all buckets from entries, compacting storage.
607    fn rebuild_buckets(&mut self) {
608        // Clear all buckets
609        for bucket in &mut self.buckets {
610            bucket.clear();
611        }
612
613        // Compact entries in-place to remove dead slots (HitId::default())
614        let mut valid_idx = 0;
615        for i in 0..self.entries.len() {
616            if self.entries[i].id != HitId::default() {
617                if i != valid_idx {
618                    self.entries[valid_idx] = self.entries[i];
619                }
620                valid_idx += 1;
621            }
622        }
623        self.entries.truncate(valid_idx);
624
625        // Rebuild lookup map from compacted entries
626        self.id_to_entry.clear();
627        for (idx, entry) in self.entries.iter().enumerate() {
628            self.id_to_entry.insert(entry.id, idx as u32);
629        }
630
631        // Rebuild buckets (separate loop to avoid borrow conflict)
632        let entry_rects: Vec<(u32, Rect)> = self
633            .entries
634            .iter()
635            .enumerate()
636            .map(|(idx, e)| (idx as u32, e.rect))
637            .collect();
638        for (idx, rect) in entry_rects {
639            self.add_to_buckets_internal(idx, rect);
640        }
641
642        self.dirty.clear();
643        self.stats.rebuilds += 1;
644    }
645
646    /// Add entry to buckets (internal, doesn't modify dirty tracker).
647    fn add_to_buckets_internal(&mut self, entry_idx: u32, rect: Rect) {
648        if rect.width == 0 || rect.height == 0 {
649            return;
650        }
651
652        let (bx_start, by_start, bx_end, by_end) = self.bucket_range(rect);
653
654        for by in by_start..=by_end {
655            for bx in bx_start..=bx_end {
656                let bucket_idx = by as usize * self.grid_width as usize + bx as usize;
657                if bucket_idx < self.buckets.len() {
658                    self.buckets[bucket_idx].push(entry_idx);
659                }
660            }
661        }
662    }
663}
664
665// ---------------------------------------------------------------------------
666// Tests
667// ---------------------------------------------------------------------------
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    fn index() -> SpatialHitIndex {
674        SpatialHitIndex::with_defaults(80, 24)
675    }
676
677    // --- Basic functionality ---
678
679    #[test]
680    fn initial_state_empty() {
681        let idx = index();
682        assert!(idx.is_empty());
683        assert_eq!(idx.len(), 0);
684    }
685
686    #[test]
687    fn register_and_hit_test() {
688        let mut idx = index();
689        idx.register_simple(
690            HitId::new(1),
691            Rect::new(10, 5, 20, 3),
692            HitRegion::Button,
693            42,
694        );
695
696        // Inside rect
697        let result = idx.hit_test(15, 6);
698        assert_eq!(result, Some((HitId::new(1), HitRegion::Button, 42)));
699
700        // Outside rect
701        assert!(idx.hit_test(5, 5).is_none());
702        assert!(idx.hit_test(35, 5).is_none());
703    }
704
705    #[test]
706    fn z_order_topmost_wins() {
707        let mut idx = index();
708
709        // Register two overlapping widgets with different z-order
710        idx.register(
711            HitId::new(1),
712            Rect::new(0, 0, 10, 10),
713            HitRegion::Content,
714            1,
715            0, // Lower z
716        );
717        idx.register(
718            HitId::new(2),
719            Rect::new(5, 5, 10, 10),
720            HitRegion::Border,
721            2,
722            1, // Higher z
723        );
724
725        // In overlap region, widget 2 should win (higher z)
726        let result = idx.hit_test(7, 7);
727        assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
728
729        // In widget 1 only region
730        let result = idx.hit_test(2, 2);
731        assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 1)));
732    }
733
734    #[test]
735    fn same_z_order_later_wins() {
736        let mut idx = index();
737
738        // Same z-order, later registration wins
739        idx.register(
740            HitId::new(1),
741            Rect::new(0, 0, 10, 10),
742            HitRegion::Content,
743            1,
744            0,
745        );
746        idx.register(
747            HitId::new(2),
748            Rect::new(5, 5, 10, 10),
749            HitRegion::Border,
750            2,
751            0,
752        );
753
754        // In overlap, widget 2 (later) should win
755        let result = idx.hit_test(7, 7);
756        assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
757    }
758
759    #[test]
760    fn hit_test_border_inclusive() {
761        let mut idx = index();
762        idx.register_simple(
763            HitId::new(1),
764            Rect::new(10, 10, 5, 5),
765            HitRegion::Content,
766            0,
767        );
768
769        // Corners should hit
770        assert!(idx.hit_test(10, 10).is_some()); // Top-left
771        assert!(idx.hit_test(14, 10).is_some()); // Top-right
772        assert!(idx.hit_test(10, 14).is_some()); // Bottom-left
773        assert!(idx.hit_test(14, 14).is_some()); // Bottom-right
774
775        // Just outside should miss
776        assert!(idx.hit_test(15, 10).is_none()); // Right of rect
777        assert!(idx.hit_test(10, 15).is_none()); // Below rect
778        assert!(idx.hit_test(9, 10).is_none()); // Left of rect
779        assert!(idx.hit_test(10, 9).is_none()); // Above rect
780    }
781
782    #[test]
783    fn update_widget_rect() {
784        let mut idx = index();
785        idx.register_simple(
786            HitId::new(1),
787            Rect::new(0, 0, 10, 10),
788            HitRegion::Content,
789            0,
790        );
791
792        // Should hit at original position
793        assert!(idx.hit_test(5, 5).is_some());
794
795        // Update position (staying within 80x24 bounds)
796        let updated = idx.update(HitId::new(1), Rect::new(50, 10, 10, 10));
797        assert!(updated);
798
799        // Should no longer hit at original position
800        assert!(idx.hit_test(5, 5).is_none());
801
802        // Should hit at new position
803        assert!(idx.hit_test(55, 15).is_some());
804    }
805
806    #[test]
807    fn remove_widget() {
808        let mut idx = index();
809        idx.register_simple(
810            HitId::new(1),
811            Rect::new(0, 0, 10, 10),
812            HitRegion::Content,
813            0,
814        );
815
816        assert!(idx.hit_test(5, 5).is_some());
817
818        let removed = idx.remove(HitId::new(1));
819        assert!(removed);
820
821        assert!(idx.hit_test(5, 5).is_none());
822        assert!(idx.is_empty());
823    }
824
825    #[test]
826    fn clear_all() {
827        let mut idx = index();
828        idx.register_simple(
829            HitId::new(1),
830            Rect::new(0, 0, 10, 10),
831            HitRegion::Content,
832            0,
833        );
834        idx.register_simple(
835            HitId::new(2),
836            Rect::new(20, 20, 10, 10),
837            HitRegion::Button,
838            1,
839        );
840
841        assert_eq!(idx.len(), 2);
842
843        idx.clear();
844
845        assert!(idx.is_empty());
846        assert!(idx.hit_test(5, 5).is_none());
847        assert!(idx.hit_test(25, 25).is_none());
848    }
849
850    // --- Cache tests ---
851
852    #[test]
853    fn cache_hit_on_same_position() {
854        let mut idx = SpatialHitIndex::new(
855            80,
856            24,
857            SpatialHitConfig {
858                track_cache_stats: true,
859                ..Default::default()
860            },
861        );
862        idx.register_simple(
863            HitId::new(1),
864            Rect::new(0, 0, 10, 10),
865            HitRegion::Content,
866            0,
867        );
868
869        // First query - miss
870        let _ = idx.hit_test(5, 5);
871        assert_eq!(idx.stats().misses, 1);
872        assert_eq!(idx.stats().hits, 0);
873
874        // Second query at same position - hit
875        let _ = idx.hit_test(5, 5);
876        assert_eq!(idx.stats().hits, 1);
877
878        // Query at different position - miss
879        let _ = idx.hit_test(7, 7);
880        assert_eq!(idx.stats().misses, 2);
881    }
882
883    #[test]
884    fn cache_invalidated_on_register() {
885        let mut idx = SpatialHitIndex::new(
886            80,
887            24,
888            SpatialHitConfig {
889                track_cache_stats: true,
890                ..Default::default()
891            },
892        );
893        idx.register_simple(
894            HitId::new(1),
895            Rect::new(0, 0, 10, 10),
896            HitRegion::Content,
897            0,
898        );
899
900        // Prime cache
901        let _ = idx.hit_test(5, 5);
902
903        // Register overlapping widget
904        idx.register_simple(HitId::new(2), Rect::new(0, 0, 10, 10), HitRegion::Button, 1);
905
906        // Cache should be invalidated, so next query is a miss
907        let hits_before = idx.stats().hits;
908        let _ = idx.hit_test(5, 5);
909        // Due to dirty tracking, cache is invalidated in overlapping region
910        assert_eq!(idx.stats().hits, hits_before);
911    }
912
913    // --- Property tests ---
914
915    #[test]
916    fn property_random_layout_correctness() {
917        let mut idx = index();
918        let widgets = vec![
919            (HitId::new(1), Rect::new(0, 0, 20, 10), 0u16),
920            (HitId::new(2), Rect::new(10, 5, 20, 10), 1),
921            (HitId::new(3), Rect::new(25, 0, 15, 15), 2),
922        ];
923
924        for (id, rect, z) in &widgets {
925            idx.register(*id, *rect, HitRegion::Content, id.id() as u64, *z);
926        }
927
928        // Test multiple points
929        for x in 0..60 {
930            for y in 0..20 {
931                let indexed_result = idx.hit_test_readonly(x, y);
932
933                // Compute expected result with naive O(n) scan
934                let mut best: Option<(HitId, u16)> = None;
935                for (id, rect, z) in &widgets {
936                    if x >= rect.x
937                        && x < rect.x + rect.width
938                        && y >= rect.y
939                        && y < rect.y + rect.height
940                    {
941                        match best {
942                            None => best = Some((*id, *z)),
943                            Some((_, best_z)) if *z > best_z => best = Some((*id, *z)),
944                            _ => {}
945                        }
946                    }
947                }
948
949                let expected_id = best.map(|(id, _)| id);
950                let indexed_id = indexed_result.map(|(id, _, _)| id);
951
952                assert_eq!(
953                    indexed_id, expected_id,
954                    "Mismatch at ({}, {}): indexed={:?}, expected={:?}",
955                    x, y, indexed_id, expected_id
956                );
957            }
958        }
959    }
960
961    // --- Edge cases ---
962
963    #[test]
964    fn out_of_bounds_returns_none() {
965        let mut idx = index();
966        idx.register_simple(
967            HitId::new(1),
968            Rect::new(0, 0, 10, 10),
969            HitRegion::Content,
970            0,
971        );
972
973        assert!(idx.hit_test(100, 100).is_none());
974        assert!(idx.hit_test(80, 0).is_none());
975        assert!(idx.hit_test(0, 24).is_none());
976    }
977
978    #[test]
979    fn zero_size_rect_ignored() {
980        let mut idx = index();
981        idx.register_simple(
982            HitId::new(1),
983            Rect::new(10, 10, 0, 0),
984            HitRegion::Content,
985            0,
986        );
987
988        // Should not hit even at the exact position
989        assert!(idx.hit_test(10, 10).is_none());
990    }
991
992    #[test]
993    fn large_rect_spans_many_buckets() {
994        let mut idx = index();
995        // Rect spans multiple buckets (80x24 with 8x8 cells = 10x3 buckets)
996        idx.register_simple(
997            HitId::new(1),
998            Rect::new(0, 0, 80, 24),
999            HitRegion::Content,
1000            0,
1001        );
1002
1003        // Should hit everywhere
1004        assert!(idx.hit_test(0, 0).is_some());
1005        assert!(idx.hit_test(40, 12).is_some());
1006        assert!(idx.hit_test(79, 23).is_some());
1007    }
1008
1009    #[test]
1010    fn update_nonexistent_returns_false() {
1011        let mut idx = index();
1012        let result = idx.update(HitId::new(999), Rect::new(0, 0, 10, 10));
1013        assert!(!result);
1014    }
1015
1016    #[test]
1017    fn remove_nonexistent_returns_false() {
1018        let mut idx = index();
1019        let result = idx.remove(HitId::new(999));
1020        assert!(!result);
1021    }
1022
1023    #[test]
1024    fn stats_hit_rate() {
1025        let mut stats = CacheStats::default();
1026        assert_eq!(stats.hit_rate(), 0.0);
1027
1028        stats.hits = 75;
1029        stats.misses = 25;
1030        assert!((stats.hit_rate() - 75.0).abs() < 0.01);
1031    }
1032
1033    #[test]
1034    fn config_defaults() {
1035        let config = SpatialHitConfig::default();
1036        assert_eq!(config.cell_size, 8);
1037        assert_eq!(config.bucket_warn_threshold, 64);
1038        assert!(!config.track_cache_stats);
1039    }
1040
1041    #[test]
1042    fn invalidate_region() {
1043        let mut idx = index();
1044        idx.register_simple(
1045            HitId::new(1),
1046            Rect::new(0, 0, 10, 10),
1047            HitRegion::Content,
1048            0,
1049        );
1050
1051        // Prime cache
1052        let _ = idx.hit_test(5, 5);
1053        assert!(idx.cache.valid);
1054
1055        // Invalidate region that includes cached position
1056        idx.invalidate_region(Rect::new(0, 0, 10, 10));
1057        assert!(!idx.cache.valid);
1058    }
1059
1060    #[test]
1061    fn invalidate_all() {
1062        let mut idx = index();
1063        idx.register_simple(
1064            HitId::new(1),
1065            Rect::new(0, 0, 10, 10),
1066            HitRegion::Content,
1067            0,
1068        );
1069
1070        let _ = idx.hit_test(5, 5);
1071        assert!(idx.cache.valid);
1072
1073        idx.invalidate_all();
1074        assert!(!idx.cache.valid);
1075    }
1076
1077    #[test]
1078    fn three_overlapping_widgets_z_order() {
1079        let mut idx = index();
1080        idx.register(
1081            HitId::new(1),
1082            Rect::new(0, 0, 20, 20),
1083            HitRegion::Content,
1084            10,
1085            0,
1086        );
1087        idx.register(
1088            HitId::new(2),
1089            Rect::new(5, 5, 15, 15),
1090            HitRegion::Border,
1091            20,
1092            2,
1093        );
1094        idx.register(
1095            HitId::new(3),
1096            Rect::new(8, 8, 10, 10),
1097            HitRegion::Button,
1098            30,
1099            1,
1100        );
1101        // At (10, 10): all three overlap; widget 2 has highest z=2
1102        let result = idx.hit_test(10, 10);
1103        assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 20)));
1104    }
1105
1106    #[test]
1107    fn hit_test_readonly_matches_mutable() {
1108        let mut idx = index();
1109        idx.register_simple(
1110            HitId::new(1),
1111            Rect::new(5, 5, 10, 10),
1112            HitRegion::Content,
1113            0,
1114        );
1115        let mutable_result = idx.hit_test(8, 8);
1116        let readonly_result = idx.hit_test_readonly(8, 8);
1117        assert_eq!(mutable_result, readonly_result);
1118    }
1119
1120    #[test]
1121    fn single_pixel_widget() {
1122        let mut idx = index();
1123        idx.register_simple(HitId::new(1), Rect::new(5, 5, 1, 1), HitRegion::Button, 0);
1124        assert!(idx.hit_test(5, 5).is_some());
1125        assert!(idx.hit_test(6, 5).is_none());
1126        assert!(idx.hit_test(5, 6).is_none());
1127    }
1128
1129    #[test]
1130    fn clear_on_empty_is_idempotent() {
1131        let mut idx = index();
1132        idx.clear();
1133        assert!(idx.is_empty());
1134        idx.clear();
1135        assert!(idx.is_empty());
1136    }
1137
1138    #[test]
1139    fn register_remove_register_cycle() {
1140        let mut idx = index();
1141        idx.register_simple(
1142            HitId::new(1),
1143            Rect::new(0, 0, 10, 10),
1144            HitRegion::Content,
1145            0,
1146        );
1147        assert_eq!(idx.len(), 1);
1148        idx.remove(HitId::new(1));
1149        assert_eq!(idx.len(), 0);
1150        idx.register_simple(HitId::new(1), Rect::new(20, 20, 5, 5), HitRegion::Border, 0);
1151        assert_eq!(idx.len(), 1);
1152        // Should hit at new location, not old
1153        assert!(idx.hit_test(22, 22).is_some());
1154        assert!(idx.hit_test(5, 5).is_none());
1155    }
1156
1157    #[test]
1158    fn invalidate_non_overlapping_region_preserves_cache() {
1159        let mut idx = index();
1160        idx.register_simple(
1161            HitId::new(1),
1162            Rect::new(0, 0, 10, 10),
1163            HitRegion::Content,
1164            0,
1165        );
1166        let _ = idx.hit_test(5, 5);
1167        assert!(idx.cache.valid);
1168        // Invalidate a region that doesn't overlap the cached point
1169        idx.invalidate_region(Rect::new(50, 50, 10, 10));
1170        assert!(idx.cache.valid);
1171    }
1172
1173    #[test]
1174    fn hit_entry_contains() {
1175        let entry = HitEntry::new(
1176            HitId::new(1),
1177            Rect::new(10, 10, 20, 20),
1178            HitRegion::Content,
1179            0,
1180            0,
1181            0,
1182        );
1183        assert!(entry.contains(15, 15));
1184        assert!(entry.contains(10, 10));
1185        assert!(!entry.contains(9, 10));
1186        assert!(!entry.contains(30, 30));
1187    }
1188
1189    #[test]
1190    fn reset_stats_clears_counters() {
1191        let mut idx = SpatialHitIndex::new(
1192            80,
1193            24,
1194            SpatialHitConfig {
1195                cell_size: 8,
1196                bucket_warn_threshold: 64,
1197                track_cache_stats: true,
1198            },
1199        );
1200        idx.register_simple(
1201            HitId::new(1),
1202            Rect::new(0, 0, 10, 10),
1203            HitRegion::Content,
1204            0,
1205        );
1206        let _ = idx.hit_test(5, 5);
1207        let _ = idx.hit_test(5, 5); // cache hit
1208        let stats = idx.stats();
1209        assert!(stats.hits > 0 || stats.misses > 0);
1210        idx.reset_stats();
1211        let stats = idx.stats();
1212        assert_eq!(stats.hits, 0);
1213        assert_eq!(stats.misses, 0);
1214    }
1215
1216    // =========================================================================
1217    // Edge-Case Tests (bd-9bvp0)
1218    // =========================================================================
1219
1220    // --- SpatialHitConfig trait coverage ---
1221
1222    #[test]
1223    fn config_debug_clone() {
1224        let config = SpatialHitConfig::default();
1225        let dbg = format!("{:?}", config);
1226        assert!(dbg.contains("SpatialHitConfig"), "Debug: {dbg}");
1227        let cloned = config.clone();
1228        assert_eq!(cloned.cell_size, 8);
1229    }
1230
1231    // --- HitEntry trait coverage ---
1232
1233    #[test]
1234    fn hit_entry_debug_clone_copy_eq() {
1235        let entry = HitEntry::new(
1236            HitId::new(1),
1237            Rect::new(0, 0, 10, 10),
1238            HitRegion::Content,
1239            42,
1240            5,
1241            0,
1242        );
1243        let dbg = format!("{:?}", entry);
1244        assert!(dbg.contains("HitEntry"), "Debug: {dbg}");
1245        let copied = entry; // Copy
1246        assert_eq!(entry, copied);
1247        let cloned: HitEntry = entry; // Clone == Copy for this type
1248        assert_eq!(entry, cloned);
1249    }
1250
1251    #[test]
1252    fn hit_entry_ne() {
1253        let a = HitEntry::new(
1254            HitId::new(1),
1255            Rect::new(0, 0, 10, 10),
1256            HitRegion::Content,
1257            0,
1258            0,
1259            0,
1260        );
1261        let b = HitEntry::new(
1262            HitId::new(2),
1263            Rect::new(0, 0, 10, 10),
1264            HitRegion::Content,
1265            0,
1266            0,
1267            0,
1268        );
1269        assert_ne!(a, b);
1270    }
1271
1272    #[test]
1273    fn hit_entry_contains_zero_width() {
1274        let entry = HitEntry::new(
1275            HitId::new(1),
1276            Rect::new(10, 10, 0, 5),
1277            HitRegion::Content,
1278            0,
1279            0,
1280            0,
1281        );
1282        // Zero width: x >= 10 && x < 10+0=10 → always false
1283        assert!(!entry.contains(10, 10));
1284    }
1285
1286    #[test]
1287    fn hit_entry_contains_zero_height() {
1288        let entry = HitEntry::new(
1289            HitId::new(1),
1290            Rect::new(10, 10, 5, 0),
1291            HitRegion::Content,
1292            0,
1293            0,
1294            0,
1295        );
1296        assert!(!entry.contains(10, 10));
1297    }
1298
1299    #[test]
1300    fn hit_entry_contains_at_saturating_boundary() {
1301        // Rect near u16::MAX tests saturating_add
1302        let entry = HitEntry::new(
1303            HitId::new(1),
1304            Rect::new(u16::MAX - 5, u16::MAX - 5, 10, 10),
1305            HitRegion::Content,
1306            0,
1307            0,
1308            0,
1309        );
1310        // saturating_add: (65530 + 10).min(65535) = 65535
1311        // contains uses strict <, so u16::MAX is excluded
1312        assert!(entry.contains(u16::MAX - 5, u16::MAX - 5));
1313        assert!(entry.contains(u16::MAX - 1, u16::MAX - 1));
1314        assert!(!entry.contains(u16::MAX, u16::MAX));
1315    }
1316
1317    // --- CacheStats ---
1318
1319    #[test]
1320    fn cache_stats_default() {
1321        let stats = CacheStats::default();
1322        assert_eq!(stats.hits, 0);
1323        assert_eq!(stats.misses, 0);
1324        assert_eq!(stats.rebuilds, 0);
1325        assert_eq!(stats.hit_rate(), 0.0);
1326    }
1327
1328    #[test]
1329    fn cache_stats_debug_copy() {
1330        let stats = CacheStats {
1331            hits: 10,
1332            misses: 5,
1333            rebuilds: 1,
1334        };
1335        let dbg = format!("{:?}", stats);
1336        assert!(dbg.contains("CacheStats"), "Debug: {dbg}");
1337        let copy = stats; // Copy
1338        assert_eq!(copy.hits, stats.hits);
1339    }
1340
1341    #[test]
1342    fn cache_stats_100_percent_hit_rate() {
1343        let stats = CacheStats {
1344            hits: 100,
1345            misses: 0,
1346            rebuilds: 0,
1347        };
1348        assert!((stats.hit_rate() - 100.0).abs() < 0.01);
1349    }
1350
1351    #[test]
1352    fn cache_stats_0_percent_hit_rate() {
1353        let stats = CacheStats {
1354            hits: 0,
1355            misses: 100,
1356            rebuilds: 0,
1357        };
1358        assert!((stats.hit_rate()).abs() < 0.01);
1359    }
1360
1361    // --- SpatialHitIndex construction ---
1362
1363    #[test]
1364    fn new_with_cell_size_zero_clamped_to_one() {
1365        let config = SpatialHitConfig {
1366            cell_size: 0,
1367            ..Default::default()
1368        };
1369        let idx = SpatialHitIndex::new(80, 24, config);
1370        // cell_size=0 clamped to 1, grid = 80x24 buckets
1371        assert_eq!(idx.grid_width, 80);
1372        assert_eq!(idx.grid_height, 24);
1373        assert!(idx.is_empty());
1374    }
1375
1376    #[test]
1377    fn new_with_cell_size_one() {
1378        let config = SpatialHitConfig {
1379            cell_size: 1,
1380            ..Default::default()
1381        };
1382        let idx = SpatialHitIndex::new(10, 5, config);
1383        // 1 bucket per cell
1384        assert_eq!(idx.grid_width, 10);
1385        assert_eq!(idx.grid_height, 5);
1386    }
1387
1388    #[test]
1389    fn new_with_large_cell_size() {
1390        let config = SpatialHitConfig {
1391            cell_size: 100,
1392            ..Default::default()
1393        };
1394        let idx = SpatialHitIndex::new(80, 24, config);
1395        // 80/100 rounds up to 1, 24/100 rounds up to 1
1396        assert_eq!(idx.grid_width, 1);
1397        assert_eq!(idx.grid_height, 1);
1398    }
1399
1400    #[test]
1401    fn new_zero_dimensions() {
1402        let idx = SpatialHitIndex::with_defaults(0, 0);
1403        assert!(idx.is_empty());
1404        // All hit tests should return None
1405        assert!(idx.hit_test_readonly(0, 0).is_none());
1406    }
1407
1408    #[test]
1409    fn with_defaults_uses_default_config() {
1410        let idx = SpatialHitIndex::with_defaults(80, 24);
1411        assert_eq!(idx.config.cell_size, 8);
1412        assert_eq!(idx.config.bucket_warn_threshold, 64);
1413        assert!(!idx.config.track_cache_stats);
1414    }
1415
1416    #[test]
1417    fn index_debug_format() {
1418        let idx = SpatialHitIndex::with_defaults(10, 10);
1419        let dbg = format!("{:?}", idx);
1420        assert!(dbg.contains("SpatialHitIndex"), "Debug: {dbg}");
1421    }
1422
1423    // --- Register edge cases ---
1424
1425    #[test]
1426    fn register_zero_width_rect_not_in_buckets() {
1427        let mut idx = index();
1428        idx.register_simple(HitId::new(1), Rect::new(5, 5, 0, 10), HitRegion::Content, 0);
1429        // Still registered (len=1) but won't be found by hit_test
1430        assert_eq!(idx.len(), 1);
1431        assert!(idx.hit_test(5, 5).is_none());
1432    }
1433
1434    #[test]
1435    fn register_zero_height_rect_not_in_buckets() {
1436        let mut idx = index();
1437        idx.register_simple(HitId::new(1), Rect::new(5, 5, 10, 0), HitRegion::Content, 0);
1438        assert_eq!(idx.len(), 1);
1439        assert!(idx.hit_test(5, 5).is_none());
1440    }
1441
1442    #[test]
1443    fn register_rect_extending_past_screen() {
1444        let mut idx = index();
1445        // Rect extends past 80x24 screen
1446        idx.register_simple(
1447            HitId::new(1),
1448            Rect::new(70, 20, 20, 10),
1449            HitRegion::Content,
1450            0,
1451        );
1452        // Should still hit within screen bounds
1453        assert!(idx.hit_test(75, 22).is_some());
1454        // Outside screen returns None
1455        assert!(idx.hit_test(85, 25).is_none());
1456    }
1457
1458    #[test]
1459    fn register_many_widgets() {
1460        let mut idx = index();
1461        for i in 0..100u32 {
1462            let x = (i % 8) as u16 * 10;
1463            let y = (i / 8) as u16 * 3;
1464            idx.register_simple(
1465                HitId::new(i + 1),
1466                Rect::new(x, y, 5, 2),
1467                HitRegion::Content,
1468                i as u64,
1469            );
1470        }
1471        assert_eq!(idx.len(), 100);
1472        // Spot check
1473        let result = idx.hit_test(2, 1);
1474        assert!(result.is_some());
1475    }
1476
1477    #[test]
1478    fn register_simple_uses_z_order_zero() {
1479        let mut idx = index();
1480        idx.register_simple(
1481            HitId::new(1),
1482            Rect::new(0, 0, 10, 10),
1483            HitRegion::Content,
1484            0,
1485        );
1486        // Register with explicit z=1 in same area
1487        idx.register(
1488            HitId::new(2),
1489            Rect::new(0, 0, 10, 10),
1490            HitRegion::Border,
1491            0,
1492            1,
1493        );
1494        // Widget 2 should win (z=1 > z=0)
1495        let result = idx.hit_test(5, 5);
1496        assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 0)));
1497    }
1498
1499    // --- Update edge cases ---
1500
1501    #[test]
1502    fn update_to_zero_size_rect() {
1503        let mut idx = index();
1504        idx.register_simple(
1505            HitId::new(1),
1506            Rect::new(0, 0, 10, 10),
1507            HitRegion::Content,
1508            0,
1509        );
1510        assert!(idx.hit_test(5, 5).is_some());
1511
1512        idx.update(HitId::new(1), Rect::new(0, 0, 0, 0));
1513        // Zero-size rect won't be in buckets
1514        assert!(idx.hit_test(0, 0).is_none());
1515    }
1516
1517    #[test]
1518    fn update_shrinks_widget() {
1519        let mut idx = index();
1520        idx.register_simple(
1521            HitId::new(1),
1522            Rect::new(0, 0, 20, 20),
1523            HitRegion::Content,
1524            0,
1525        );
1526        assert!(idx.hit_test(15, 15).is_some());
1527
1528        idx.update(HitId::new(1), Rect::new(0, 0, 5, 5));
1529        assert!(idx.hit_test(15, 15).is_none());
1530        assert!(idx.hit_test(2, 2).is_some());
1531    }
1532
1533    // --- Remove edge cases ---
1534
1535    #[test]
1536    fn remove_middle_entry_compacts() {
1537        let mut idx = index();
1538        idx.register_simple(HitId::new(1), Rect::new(0, 0, 5, 5), HitRegion::Content, 10);
1539        idx.register_simple(
1540            HitId::new(2),
1541            Rect::new(10, 0, 5, 5),
1542            HitRegion::Content,
1543            20,
1544        );
1545        idx.register_simple(
1546            HitId::new(3),
1547            Rect::new(20, 0, 5, 5),
1548            HitRegion::Content,
1549            30,
1550        );
1551        assert_eq!(idx.len(), 3);
1552
1553        idx.remove(HitId::new(2));
1554        assert_eq!(idx.len(), 2);
1555
1556        // Widget 1 and 3 should still work
1557        let r1 = idx.hit_test(2, 2);
1558        assert_eq!(r1, Some((HitId::new(1), HitRegion::Content, 10)));
1559        let r3 = idx.hit_test(22, 2);
1560        assert_eq!(r3, Some((HitId::new(3), HitRegion::Content, 30)));
1561    }
1562
1563    #[test]
1564    fn double_remove_returns_false() {
1565        let mut idx = index();
1566        idx.register_simple(
1567            HitId::new(1),
1568            Rect::new(0, 0, 10, 10),
1569            HitRegion::Content,
1570            0,
1571        );
1572        assert!(idx.remove(HitId::new(1)));
1573        assert!(!idx.remove(HitId::new(1)));
1574    }
1575
1576    // --- hit_test edge cases ---
1577
1578    #[test]
1579    fn hit_test_at_exact_screen_boundary() {
1580        let mut idx = index(); // 80x24
1581        idx.register_simple(
1582            HitId::new(1),
1583            Rect::new(70, 20, 10, 4),
1584            HitRegion::Content,
1585            0,
1586        );
1587        // Last valid pixel
1588        assert!(idx.hit_test(79, 23).is_some());
1589        // One past screen
1590        assert!(idx.hit_test(80, 23).is_none());
1591        assert!(idx.hit_test(79, 24).is_none());
1592    }
1593
1594    #[test]
1595    fn hit_test_at_grid_cell_boundaries() {
1596        let mut idx = index(); // cell_size=8
1597        idx.register_simple(
1598            HitId::new(1),
1599            Rect::new(6, 6, 4, 4), // spans cells (0,0) and (1,1)
1600            HitRegion::Content,
1601            0,
1602        );
1603        // At x=7,y=7 (cell 0,0) - should hit
1604        assert!(idx.hit_test(7, 7).is_some());
1605        // At x=8,y=8 (cell 1,1) - should hit
1606        assert!(idx.hit_test(8, 8).is_some());
1607        // At x=9,y=9 (cell 1,1) - should hit
1608        assert!(idx.hit_test(9, 9).is_some());
1609        // At x=10,y=10 (cell 1,1) - outside rect
1610        assert!(idx.hit_test(10, 10).is_none());
1611    }
1612
1613    #[test]
1614    fn hit_test_readonly_out_of_bounds() {
1615        let idx = index();
1616        assert!(idx.hit_test_readonly(80, 0).is_none());
1617        assert!(idx.hit_test_readonly(0, 24).is_none());
1618        assert!(idx.hit_test_readonly(u16::MAX, u16::MAX).is_none());
1619    }
1620
1621    #[test]
1622    fn hit_test_readonly_skips_removed() {
1623        let mut idx = index();
1624        idx.register_simple(
1625            HitId::new(1),
1626            Rect::new(0, 0, 10, 10),
1627            HitRegion::Content,
1628            0,
1629        );
1630        idx.register_simple(HitId::new(2), Rect::new(0, 0, 10, 10), HitRegion::Border, 1);
1631        idx.remove(HitId::new(2));
1632        // Widget 1 should still be found
1633        let result = idx.hit_test_readonly(5, 5);
1634        assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 0)));
1635    }
1636
1637    // --- Cache behavior ---
1638
1639    #[test]
1640    fn cache_updates_on_different_positions() {
1641        let mut idx = SpatialHitIndex::new(
1642            80,
1643            24,
1644            SpatialHitConfig {
1645                track_cache_stats: true,
1646                ..Default::default()
1647            },
1648        );
1649        idx.register_simple(
1650            HitId::new(1),
1651            Rect::new(0, 0, 40, 12),
1652            HitRegion::Content,
1653            1,
1654        );
1655        idx.register_simple(
1656            HitId::new(2),
1657            Rect::new(40, 12, 40, 12),
1658            HitRegion::Border,
1659            2,
1660        );
1661
1662        // First query
1663        let r1 = idx.hit_test(5, 5);
1664        assert_eq!(r1, Some((HitId::new(1), HitRegion::Content, 1)));
1665        assert_eq!(idx.stats().misses, 1);
1666
1667        // Different position - miss
1668        let r2 = idx.hit_test(50, 15);
1669        assert_eq!(r2, Some((HitId::new(2), HitRegion::Border, 2)));
1670        assert_eq!(idx.stats().misses, 2);
1671
1672        // Back to first position - miss (cache only stores one position)
1673        let _ = idx.hit_test(5, 5);
1674        assert_eq!(idx.stats().misses, 3);
1675    }
1676
1677    #[test]
1678    fn cache_invalidated_by_invalidate_all_then_same_position() {
1679        let mut idx = SpatialHitIndex::new(
1680            80,
1681            24,
1682            SpatialHitConfig {
1683                track_cache_stats: true,
1684                ..Default::default()
1685            },
1686        );
1687        idx.register_simple(
1688            HitId::new(1),
1689            Rect::new(0, 0, 10, 10),
1690            HitRegion::Content,
1691            0,
1692        );
1693
1694        // Prime cache
1695        let _ = idx.hit_test(5, 5);
1696        assert_eq!(idx.stats().misses, 1);
1697        assert_eq!(idx.stats().hits, 0);
1698
1699        // Invalidate all then query same position
1700        idx.invalidate_all();
1701        let _ = idx.hit_test(5, 5);
1702        // Should be a miss since cache was invalidated
1703        assert_eq!(idx.stats().misses, 2);
1704    }
1705
1706    #[test]
1707    fn cache_not_updated_by_readonly() {
1708        let mut idx = SpatialHitIndex::new(
1709            80,
1710            24,
1711            SpatialHitConfig {
1712                track_cache_stats: true,
1713                ..Default::default()
1714            },
1715        );
1716        idx.register_simple(
1717            HitId::new(1),
1718            Rect::new(0, 0, 10, 10),
1719            HitRegion::Content,
1720            0,
1721        );
1722
1723        // readonly doesn't update cache
1724        let _ = idx.hit_test_readonly(5, 5);
1725        assert_eq!(idx.stats().hits, 0);
1726        assert_eq!(idx.stats().misses, 0);
1727
1728        // mutable query at same position should be a miss
1729        let _ = idx.hit_test(5, 5);
1730        assert_eq!(idx.stats().misses, 1);
1731    }
1732
1733    // --- Invalidation edge cases ---
1734
1735    #[test]
1736    fn invalidate_region_zero_size() {
1737        let mut idx = index();
1738        idx.register_simple(
1739            HitId::new(1),
1740            Rect::new(0, 0, 10, 10),
1741            HitRegion::Content,
1742            0,
1743        );
1744        let _ = idx.hit_test(5, 5);
1745        assert!(idx.cache.valid);
1746
1747        // Zero-size rect shouldn't invalidate anything
1748        idx.invalidate_region(Rect::new(5, 5, 0, 0));
1749        assert!(idx.cache.valid);
1750    }
1751
1752    #[test]
1753    fn invalidate_region_outside_screen() {
1754        let mut idx = index();
1755        idx.register_simple(
1756            HitId::new(1),
1757            Rect::new(0, 0, 10, 10),
1758            HitRegion::Content,
1759            0,
1760        );
1761        let _ = idx.hit_test(5, 5);
1762        assert!(idx.cache.valid);
1763
1764        // Region outside screen
1765        idx.invalidate_region(Rect::new(100, 100, 10, 10));
1766        // Cache position (5,5) is not in the dirty region, so cache stays valid
1767        assert!(idx.cache.valid);
1768    }
1769
1770    // --- Rebuild tracking ---
1771
1772    #[test]
1773    fn rebuild_counted_in_stats() {
1774        let mut idx = SpatialHitIndex::new(
1775            80,
1776            24,
1777            SpatialHitConfig {
1778                track_cache_stats: true,
1779                ..Default::default()
1780            },
1781        );
1782        idx.register_simple(
1783            HitId::new(1),
1784            Rect::new(0, 0, 10, 10),
1785            HitRegion::Content,
1786            0,
1787        );
1788        assert_eq!(idx.stats().rebuilds, 0);
1789
1790        // Update triggers rebuild
1791        idx.update(HitId::new(1), Rect::new(10, 10, 5, 5));
1792        assert_eq!(idx.stats().rebuilds, 1);
1793
1794        // Remove triggers rebuild
1795        idx.remove(HitId::new(1));
1796        assert_eq!(idx.stats().rebuilds, 2);
1797    }
1798
1799    // --- Full lifecycle ---
1800
1801    #[test]
1802    fn register_hit_update_hit_remove_clear() {
1803        let mut idx = index();
1804
1805        // Register
1806        idx.register_simple(
1807            HitId::new(1),
1808            Rect::new(0, 0, 10, 10),
1809            HitRegion::Content,
1810            0,
1811        );
1812        assert_eq!(idx.len(), 1);
1813
1814        // Hit
1815        assert!(idx.hit_test(5, 5).is_some());
1816
1817        // Update
1818        idx.update(HitId::new(1), Rect::new(20, 20, 10, 10));
1819        assert!(idx.hit_test(5, 5).is_none());
1820        assert!(idx.hit_test(25, 22).is_some());
1821
1822        // Remove
1823        idx.remove(HitId::new(1));
1824        assert!(idx.is_empty());
1825        assert!(idx.hit_test(25, 22).is_none());
1826
1827        // Re-register
1828        idx.register_simple(HitId::new(2), Rect::new(0, 0, 5, 5), HitRegion::Button, 99);
1829        assert_eq!(idx.len(), 1);
1830        let r = idx.hit_test(2, 2);
1831        assert_eq!(r, Some((HitId::new(2), HitRegion::Button, 99)));
1832
1833        // Clear
1834        idx.clear();
1835        assert!(idx.is_empty());
1836        assert!(idx.hit_test(2, 2).is_none());
1837    }
1838
1839    // --- Z-order tie-breaking ---
1840
1841    #[test]
1842    fn z_order_tie_broken_by_registration_order() {
1843        let mut idx = index();
1844        // Same z_order=5, different registration order
1845        idx.register(
1846            HitId::new(1),
1847            Rect::new(0, 0, 10, 10),
1848            HitRegion::Content,
1849            10,
1850            5,
1851        );
1852        idx.register(
1853            HitId::new(2),
1854            Rect::new(0, 0, 10, 10),
1855            HitRegion::Border,
1856            20,
1857            5,
1858        );
1859        idx.register(
1860            HitId::new(3),
1861            Rect::new(0, 0, 10, 10),
1862            HitRegion::Button,
1863            30,
1864            5,
1865        );
1866
1867        // Widget 3 wins (latest registration at same z)
1868        let result = idx.hit_test(5, 5);
1869        assert_eq!(result, Some((HitId::new(3), HitRegion::Button, 30)));
1870    }
1871
1872    #[test]
1873    fn z_order_higher_z_beats_later_registration() {
1874        let mut idx = index();
1875        // Widget 1: z=10, registered first
1876        idx.register(
1877            HitId::new(1),
1878            Rect::new(0, 0, 10, 10),
1879            HitRegion::Content,
1880            10,
1881            10,
1882        );
1883        // Widget 2: z=5, registered later
1884        idx.register(
1885            HitId::new(2),
1886            Rect::new(0, 0, 10, 10),
1887            HitRegion::Border,
1888            20,
1889            5,
1890        );
1891
1892        // Widget 1 wins (higher z trumps registration order)
1893        let result = idx.hit_test(5, 5);
1894        assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 10)));
1895    }
1896
1897    // --- HitRegion variants in hit_test ---
1898
1899    #[test]
1900    fn all_hit_region_variants_returned() {
1901        let mut idx = index();
1902        let regions = [
1903            (1, HitRegion::Content),
1904            (2, HitRegion::Border),
1905            (3, HitRegion::Scrollbar),
1906            (4, HitRegion::Handle),
1907            (5, HitRegion::Button),
1908            (6, HitRegion::Link),
1909            (7, HitRegion::Custom(42)),
1910        ];
1911        for (i, (id, region)) in regions.iter().enumerate() {
1912            let x = (i as u16) * 10;
1913            idx.register_simple(HitId::new(*id), Rect::new(x, 0, 5, 5), *region, *id as u64);
1914        }
1915        for (i, (id, region)) in regions.iter().enumerate() {
1916            let x = (i as u16) * 10 + 2;
1917            let result = idx.hit_test(x, 2);
1918            assert_eq!(
1919                result,
1920                Some((HitId::new(*id), *region, *id as u64)),
1921                "Failed for region {:?}",
1922                region
1923            );
1924        }
1925    }
1926
1927    // --- Width=1 and Height=1 edge ---
1928
1929    #[test]
1930    fn single_cell_screen() {
1931        let mut idx = SpatialHitIndex::with_defaults(1, 1);
1932        idx.register_simple(HitId::new(1), Rect::new(0, 0, 1, 1), HitRegion::Content, 0);
1933        assert!(idx.hit_test(0, 0).is_some());
1934        assert!(idx.hit_test(1, 0).is_none());
1935    }
1936
1937    // --- Readonly equivalence across whole grid ---
1938
1939    #[test]
1940    fn hit_test_readonly_equivalent_to_mutable_for_grid() {
1941        let mut idx = index();
1942        idx.register(
1943            HitId::new(1),
1944            Rect::new(0, 0, 40, 12),
1945            HitRegion::Content,
1946            1,
1947            0,
1948        );
1949        idx.register(
1950            HitId::new(2),
1951            Rect::new(30, 8, 20, 10),
1952            HitRegion::Border,
1953            2,
1954            1,
1955        );
1956        idx.register(
1957            HitId::new(3),
1958            Rect::new(60, 0, 20, 24),
1959            HitRegion::Button,
1960            3,
1961            2,
1962        );
1963
1964        // Compare at grid of points
1965        for x in (0..80).step_by(5) {
1966            for y in (0..24).step_by(3) {
1967                let ro = idx.hit_test_readonly(x, y);
1968                let expected_id = ro.map(|(id, _, _)| id);
1969                // We can't use hit_test (mutates cache) in a fair comparison loop,
1970                // so just verify readonly is consistent with itself
1971                let ro2 = idx.hit_test_readonly(x, y);
1972                assert_eq!(ro, ro2, "Readonly inconsistency at ({x}, {y})");
1973                // Also verify against mutable
1974                let mut_result = idx.hit_test(x, y);
1975                let mut_id = mut_result.map(|(id, _, _)| id);
1976                assert_eq!(
1977                    expected_id, mut_id,
1978                    "Mutable/readonly mismatch at ({x}, {y})"
1979                );
1980            }
1981        }
1982    }
1983}