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 by iterating over entry indices to avoid borrowing
632        // the entire entries vector while modifying buckets.
633        for idx in 0..self.entries.len() {
634            let rect = self.entries[idx].rect;
635            self.add_to_buckets_internal(idx as u32, rect);
636        }
637
638        self.dirty.clear();
639        self.stats.rebuilds += 1;
640    }
641
642    /// Add entry to buckets (internal, doesn't modify dirty tracker).
643    fn add_to_buckets_internal(&mut self, entry_idx: u32, rect: Rect) {
644        if rect.width == 0 || rect.height == 0 {
645            return;
646        }
647
648        let (bx_start, by_start, bx_end, by_end) = self.bucket_range(rect);
649
650        for by in by_start..=by_end {
651            for bx in bx_start..=bx_end {
652                let bucket_idx = by as usize * self.grid_width as usize + bx as usize;
653                if bucket_idx < self.buckets.len() {
654                    self.buckets[bucket_idx].push(entry_idx);
655                }
656            }
657        }
658    }
659}
660
661// ---------------------------------------------------------------------------
662// Tests
663// ---------------------------------------------------------------------------
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    fn index() -> SpatialHitIndex {
670        SpatialHitIndex::with_defaults(80, 24)
671    }
672
673    // --- Basic functionality ---
674
675    #[test]
676    fn initial_state_empty() {
677        let idx = index();
678        assert!(idx.is_empty());
679        assert_eq!(idx.len(), 0);
680    }
681
682    #[test]
683    fn register_and_hit_test() {
684        let mut idx = index();
685        idx.register_simple(
686            HitId::new(1),
687            Rect::new(10, 5, 20, 3),
688            HitRegion::Button,
689            42,
690        );
691
692        // Inside rect
693        let result = idx.hit_test(15, 6);
694        assert_eq!(result, Some((HitId::new(1), HitRegion::Button, 42)));
695
696        // Outside rect
697        assert!(idx.hit_test(5, 5).is_none());
698        assert!(idx.hit_test(35, 5).is_none());
699    }
700
701    #[test]
702    fn z_order_topmost_wins() {
703        let mut idx = index();
704
705        // Register two overlapping widgets with different z-order
706        idx.register(
707            HitId::new(1),
708            Rect::new(0, 0, 10, 10),
709            HitRegion::Content,
710            1,
711            0, // Lower z
712        );
713        idx.register(
714            HitId::new(2),
715            Rect::new(5, 5, 10, 10),
716            HitRegion::Border,
717            2,
718            1, // Higher z
719        );
720
721        // In overlap region, widget 2 should win (higher z)
722        let result = idx.hit_test(7, 7);
723        assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
724
725        // In widget 1 only region
726        let result = idx.hit_test(2, 2);
727        assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 1)));
728    }
729
730    #[test]
731    fn same_z_order_later_wins() {
732        let mut idx = index();
733
734        // Same z-order, later registration wins
735        idx.register(
736            HitId::new(1),
737            Rect::new(0, 0, 10, 10),
738            HitRegion::Content,
739            1,
740            0,
741        );
742        idx.register(
743            HitId::new(2),
744            Rect::new(5, 5, 10, 10),
745            HitRegion::Border,
746            2,
747            0,
748        );
749
750        // In overlap, widget 2 (later) should win
751        let result = idx.hit_test(7, 7);
752        assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
753    }
754
755    #[test]
756    fn hit_test_border_inclusive() {
757        let mut idx = index();
758        idx.register_simple(
759            HitId::new(1),
760            Rect::new(10, 10, 5, 5),
761            HitRegion::Content,
762            0,
763        );
764
765        // Corners should hit
766        assert!(idx.hit_test(10, 10).is_some()); // Top-left
767        assert!(idx.hit_test(14, 10).is_some()); // Top-right
768        assert!(idx.hit_test(10, 14).is_some()); // Bottom-left
769        assert!(idx.hit_test(14, 14).is_some()); // Bottom-right
770
771        // Just outside should miss
772        assert!(idx.hit_test(15, 10).is_none()); // Right of rect
773        assert!(idx.hit_test(10, 15).is_none()); // Below rect
774        assert!(idx.hit_test(9, 10).is_none()); // Left of rect
775        assert!(idx.hit_test(10, 9).is_none()); // Above rect
776    }
777
778    #[test]
779    fn update_widget_rect() {
780        let mut idx = index();
781        idx.register_simple(
782            HitId::new(1),
783            Rect::new(0, 0, 10, 10),
784            HitRegion::Content,
785            0,
786        );
787
788        // Should hit at original position
789        assert!(idx.hit_test(5, 5).is_some());
790
791        // Update position (staying within 80x24 bounds)
792        let updated = idx.update(HitId::new(1), Rect::new(50, 10, 10, 10));
793        assert!(updated);
794
795        // Should no longer hit at original position
796        assert!(idx.hit_test(5, 5).is_none());
797
798        // Should hit at new position
799        assert!(idx.hit_test(55, 15).is_some());
800    }
801
802    #[test]
803    fn remove_widget() {
804        let mut idx = index();
805        idx.register_simple(
806            HitId::new(1),
807            Rect::new(0, 0, 10, 10),
808            HitRegion::Content,
809            0,
810        );
811
812        assert!(idx.hit_test(5, 5).is_some());
813
814        let removed = idx.remove(HitId::new(1));
815        assert!(removed);
816
817        assert!(idx.hit_test(5, 5).is_none());
818        assert!(idx.is_empty());
819    }
820
821    #[test]
822    fn clear_all() {
823        let mut idx = index();
824        idx.register_simple(
825            HitId::new(1),
826            Rect::new(0, 0, 10, 10),
827            HitRegion::Content,
828            0,
829        );
830        idx.register_simple(
831            HitId::new(2),
832            Rect::new(20, 20, 10, 10),
833            HitRegion::Button,
834            1,
835        );
836
837        assert_eq!(idx.len(), 2);
838
839        idx.clear();
840
841        assert!(idx.is_empty());
842        assert!(idx.hit_test(5, 5).is_none());
843        assert!(idx.hit_test(25, 25).is_none());
844    }
845
846    // --- Cache tests ---
847
848    #[test]
849    fn cache_hit_on_same_position() {
850        let mut idx = SpatialHitIndex::new(
851            80,
852            24,
853            SpatialHitConfig {
854                track_cache_stats: true,
855                ..Default::default()
856            },
857        );
858        idx.register_simple(
859            HitId::new(1),
860            Rect::new(0, 0, 10, 10),
861            HitRegion::Content,
862            0,
863        );
864
865        // First query - miss
866        let _ = idx.hit_test(5, 5);
867        assert_eq!(idx.stats().misses, 1);
868        assert_eq!(idx.stats().hits, 0);
869
870        // Second query at same position - hit
871        let _ = idx.hit_test(5, 5);
872        assert_eq!(idx.stats().hits, 1);
873
874        // Query at different position - miss
875        let _ = idx.hit_test(7, 7);
876        assert_eq!(idx.stats().misses, 2);
877    }
878
879    #[test]
880    fn cache_invalidated_on_register() {
881        let mut idx = SpatialHitIndex::new(
882            80,
883            24,
884            SpatialHitConfig {
885                track_cache_stats: true,
886                ..Default::default()
887            },
888        );
889        idx.register_simple(
890            HitId::new(1),
891            Rect::new(0, 0, 10, 10),
892            HitRegion::Content,
893            0,
894        );
895
896        // Prime cache
897        let _ = idx.hit_test(5, 5);
898
899        // Register overlapping widget
900        idx.register_simple(HitId::new(2), Rect::new(0, 0, 10, 10), HitRegion::Button, 1);
901
902        // Cache should be invalidated, so next query is a miss
903        let hits_before = idx.stats().hits;
904        let _ = idx.hit_test(5, 5);
905        // Due to dirty tracking, cache is invalidated in overlapping region
906        assert_eq!(idx.stats().hits, hits_before);
907    }
908
909    // --- Property tests ---
910
911    #[test]
912    fn property_random_layout_correctness() {
913        let mut idx = index();
914        let widgets = vec![
915            (HitId::new(1), Rect::new(0, 0, 20, 10), 0u16),
916            (HitId::new(2), Rect::new(10, 5, 20, 10), 1),
917            (HitId::new(3), Rect::new(25, 0, 15, 15), 2),
918        ];
919
920        for (id, rect, z) in &widgets {
921            idx.register(*id, *rect, HitRegion::Content, id.id() as u64, *z);
922        }
923
924        // Test multiple points
925        for x in 0..60 {
926            for y in 0..20 {
927                let indexed_result = idx.hit_test_readonly(x, y);
928
929                // Compute expected result with naive O(n) scan
930                let mut best: Option<(HitId, u16)> = None;
931                for (id, rect, z) in &widgets {
932                    if x >= rect.x
933                        && x < rect.x + rect.width
934                        && y >= rect.y
935                        && y < rect.y + rect.height
936                    {
937                        match best {
938                            None => best = Some((*id, *z)),
939                            Some((_, best_z)) if *z > best_z => best = Some((*id, *z)),
940                            _ => {}
941                        }
942                    }
943                }
944
945                let expected_id = best.map(|(id, _)| id);
946                let indexed_id = indexed_result.map(|(id, _, _)| id);
947
948                assert_eq!(
949                    indexed_id, expected_id,
950                    "Mismatch at ({}, {}): indexed={:?}, expected={:?}",
951                    x, y, indexed_id, expected_id
952                );
953            }
954        }
955    }
956
957    // --- Edge cases ---
958
959    #[test]
960    fn out_of_bounds_returns_none() {
961        let mut idx = index();
962        idx.register_simple(
963            HitId::new(1),
964            Rect::new(0, 0, 10, 10),
965            HitRegion::Content,
966            0,
967        );
968
969        assert!(idx.hit_test(100, 100).is_none());
970        assert!(idx.hit_test(80, 0).is_none());
971        assert!(idx.hit_test(0, 24).is_none());
972    }
973
974    #[test]
975    fn zero_size_rect_ignored() {
976        let mut idx = index();
977        idx.register_simple(
978            HitId::new(1),
979            Rect::new(10, 10, 0, 0),
980            HitRegion::Content,
981            0,
982        );
983
984        // Should not hit even at the exact position
985        assert!(idx.hit_test(10, 10).is_none());
986    }
987
988    #[test]
989    fn large_rect_spans_many_buckets() {
990        let mut idx = index();
991        // Rect spans multiple buckets (80x24 with 8x8 cells = 10x3 buckets)
992        idx.register_simple(
993            HitId::new(1),
994            Rect::new(0, 0, 80, 24),
995            HitRegion::Content,
996            0,
997        );
998
999        // Should hit everywhere
1000        assert!(idx.hit_test(0, 0).is_some());
1001        assert!(idx.hit_test(40, 12).is_some());
1002        assert!(idx.hit_test(79, 23).is_some());
1003    }
1004
1005    #[test]
1006    fn update_nonexistent_returns_false() {
1007        let mut idx = index();
1008        let result = idx.update(HitId::new(999), Rect::new(0, 0, 10, 10));
1009        assert!(!result);
1010    }
1011
1012    #[test]
1013    fn remove_nonexistent_returns_false() {
1014        let mut idx = index();
1015        let result = idx.remove(HitId::new(999));
1016        assert!(!result);
1017    }
1018
1019    #[test]
1020    fn stats_hit_rate() {
1021        let mut stats = CacheStats::default();
1022        assert_eq!(stats.hit_rate(), 0.0);
1023
1024        stats.hits = 75;
1025        stats.misses = 25;
1026        assert!((stats.hit_rate() - 75.0).abs() < 0.01);
1027    }
1028
1029    #[test]
1030    fn config_defaults() {
1031        let config = SpatialHitConfig::default();
1032        assert_eq!(config.cell_size, 8);
1033        assert_eq!(config.bucket_warn_threshold, 64);
1034        assert!(!config.track_cache_stats);
1035    }
1036
1037    #[test]
1038    fn invalidate_region() {
1039        let mut idx = index();
1040        idx.register_simple(
1041            HitId::new(1),
1042            Rect::new(0, 0, 10, 10),
1043            HitRegion::Content,
1044            0,
1045        );
1046
1047        // Prime cache
1048        let _ = idx.hit_test(5, 5);
1049        assert!(idx.cache.valid);
1050
1051        // Invalidate region that includes cached position
1052        idx.invalidate_region(Rect::new(0, 0, 10, 10));
1053        assert!(!idx.cache.valid);
1054    }
1055
1056    #[test]
1057    fn invalidate_all() {
1058        let mut idx = index();
1059        idx.register_simple(
1060            HitId::new(1),
1061            Rect::new(0, 0, 10, 10),
1062            HitRegion::Content,
1063            0,
1064        );
1065
1066        let _ = idx.hit_test(5, 5);
1067        assert!(idx.cache.valid);
1068
1069        idx.invalidate_all();
1070        assert!(!idx.cache.valid);
1071    }
1072
1073    #[test]
1074    fn three_overlapping_widgets_z_order() {
1075        let mut idx = index();
1076        idx.register(
1077            HitId::new(1),
1078            Rect::new(0, 0, 20, 20),
1079            HitRegion::Content,
1080            10,
1081            0,
1082        );
1083        idx.register(
1084            HitId::new(2),
1085            Rect::new(5, 5, 15, 15),
1086            HitRegion::Border,
1087            20,
1088            2,
1089        );
1090        idx.register(
1091            HitId::new(3),
1092            Rect::new(8, 8, 10, 10),
1093            HitRegion::Button,
1094            30,
1095            1,
1096        );
1097        // At (10, 10): all three overlap; widget 2 has highest z=2
1098        let result = idx.hit_test(10, 10);
1099        assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 20)));
1100    }
1101
1102    #[test]
1103    fn hit_test_readonly_matches_mutable() {
1104        let mut idx = index();
1105        idx.register_simple(
1106            HitId::new(1),
1107            Rect::new(5, 5, 10, 10),
1108            HitRegion::Content,
1109            0,
1110        );
1111        let mutable_result = idx.hit_test(8, 8);
1112        let readonly_result = idx.hit_test_readonly(8, 8);
1113        assert_eq!(mutable_result, readonly_result);
1114    }
1115
1116    #[test]
1117    fn single_pixel_widget() {
1118        let mut idx = index();
1119        idx.register_simple(HitId::new(1), Rect::new(5, 5, 1, 1), HitRegion::Button, 0);
1120        assert!(idx.hit_test(5, 5).is_some());
1121        assert!(idx.hit_test(6, 5).is_none());
1122        assert!(idx.hit_test(5, 6).is_none());
1123    }
1124
1125    #[test]
1126    fn clear_on_empty_is_idempotent() {
1127        let mut idx = index();
1128        idx.clear();
1129        assert!(idx.is_empty());
1130        idx.clear();
1131        assert!(idx.is_empty());
1132    }
1133
1134    #[test]
1135    fn register_remove_register_cycle() {
1136        let mut idx = index();
1137        idx.register_simple(
1138            HitId::new(1),
1139            Rect::new(0, 0, 10, 10),
1140            HitRegion::Content,
1141            0,
1142        );
1143        assert_eq!(idx.len(), 1);
1144        idx.remove(HitId::new(1));
1145        assert_eq!(idx.len(), 0);
1146        idx.register_simple(HitId::new(1), Rect::new(20, 20, 5, 5), HitRegion::Border, 0);
1147        assert_eq!(idx.len(), 1);
1148        // Should hit at new location, not old
1149        assert!(idx.hit_test(22, 22).is_some());
1150        assert!(idx.hit_test(5, 5).is_none());
1151    }
1152
1153    #[test]
1154    fn invalidate_non_overlapping_region_preserves_cache() {
1155        let mut idx = index();
1156        idx.register_simple(
1157            HitId::new(1),
1158            Rect::new(0, 0, 10, 10),
1159            HitRegion::Content,
1160            0,
1161        );
1162        let _ = idx.hit_test(5, 5);
1163        assert!(idx.cache.valid);
1164        // Invalidate a region that doesn't overlap the cached point
1165        idx.invalidate_region(Rect::new(50, 50, 10, 10));
1166        assert!(idx.cache.valid);
1167    }
1168
1169    #[test]
1170    fn hit_entry_contains() {
1171        let entry = HitEntry::new(
1172            HitId::new(1),
1173            Rect::new(10, 10, 20, 20),
1174            HitRegion::Content,
1175            0,
1176            0,
1177            0,
1178        );
1179        assert!(entry.contains(15, 15));
1180        assert!(entry.contains(10, 10));
1181        assert!(!entry.contains(9, 10));
1182        assert!(!entry.contains(30, 30));
1183    }
1184
1185    #[test]
1186    fn reset_stats_clears_counters() {
1187        let mut idx = SpatialHitIndex::new(
1188            80,
1189            24,
1190            SpatialHitConfig {
1191                cell_size: 8,
1192                bucket_warn_threshold: 64,
1193                track_cache_stats: true,
1194            },
1195        );
1196        idx.register_simple(
1197            HitId::new(1),
1198            Rect::new(0, 0, 10, 10),
1199            HitRegion::Content,
1200            0,
1201        );
1202        let _ = idx.hit_test(5, 5);
1203        let _ = idx.hit_test(5, 5); // cache hit
1204        let stats = idx.stats();
1205        assert!(stats.hits > 0 || stats.misses > 0);
1206        idx.reset_stats();
1207        let stats = idx.stats();
1208        assert_eq!(stats.hits, 0);
1209        assert_eq!(stats.misses, 0);
1210    }
1211
1212    // =========================================================================
1213    // Edge-Case Tests (bd-9bvp0)
1214    // =========================================================================
1215
1216    // --- SpatialHitConfig trait coverage ---
1217
1218    #[test]
1219    fn config_debug_clone() {
1220        let config = SpatialHitConfig::default();
1221        let dbg = format!("{:?}", config);
1222        assert!(dbg.contains("SpatialHitConfig"), "Debug: {dbg}");
1223        let cloned = config.clone();
1224        assert_eq!(cloned.cell_size, 8);
1225    }
1226
1227    // --- HitEntry trait coverage ---
1228
1229    #[test]
1230    fn hit_entry_debug_clone_copy_eq() {
1231        let entry = HitEntry::new(
1232            HitId::new(1),
1233            Rect::new(0, 0, 10, 10),
1234            HitRegion::Content,
1235            42,
1236            5,
1237            0,
1238        );
1239        let dbg = format!("{:?}", entry);
1240        assert!(dbg.contains("HitEntry"), "Debug: {dbg}");
1241        let copied = entry; // Copy
1242        assert_eq!(entry, copied);
1243        let cloned: HitEntry = entry; // Clone == Copy for this type
1244        assert_eq!(entry, cloned);
1245    }
1246
1247    #[test]
1248    fn hit_entry_ne() {
1249        let a = HitEntry::new(
1250            HitId::new(1),
1251            Rect::new(0, 0, 10, 10),
1252            HitRegion::Content,
1253            0,
1254            0,
1255            0,
1256        );
1257        let b = HitEntry::new(
1258            HitId::new(2),
1259            Rect::new(0, 0, 10, 10),
1260            HitRegion::Content,
1261            0,
1262            0,
1263            0,
1264        );
1265        assert_ne!(a, b);
1266    }
1267
1268    #[test]
1269    fn hit_entry_contains_zero_width() {
1270        let entry = HitEntry::new(
1271            HitId::new(1),
1272            Rect::new(10, 10, 0, 5),
1273            HitRegion::Content,
1274            0,
1275            0,
1276            0,
1277        );
1278        // Zero width: x >= 10 && x < 10+0=10 → always false
1279        assert!(!entry.contains(10, 10));
1280    }
1281
1282    #[test]
1283    fn hit_entry_contains_zero_height() {
1284        let entry = HitEntry::new(
1285            HitId::new(1),
1286            Rect::new(10, 10, 5, 0),
1287            HitRegion::Content,
1288            0,
1289            0,
1290            0,
1291        );
1292        assert!(!entry.contains(10, 10));
1293    }
1294
1295    #[test]
1296    fn hit_entry_contains_at_saturating_boundary() {
1297        // Rect near u16::MAX tests saturating_add
1298        let entry = HitEntry::new(
1299            HitId::new(1),
1300            Rect::new(u16::MAX - 5, u16::MAX - 5, 10, 10),
1301            HitRegion::Content,
1302            0,
1303            0,
1304            0,
1305        );
1306        // saturating_add: (65530 + 10).min(65535) = 65535
1307        // contains uses strict <, so u16::MAX is excluded
1308        assert!(entry.contains(u16::MAX - 5, u16::MAX - 5));
1309        assert!(entry.contains(u16::MAX - 1, u16::MAX - 1));
1310        assert!(!entry.contains(u16::MAX, u16::MAX));
1311    }
1312
1313    // --- CacheStats ---
1314
1315    #[test]
1316    fn cache_stats_default() {
1317        let stats = CacheStats::default();
1318        assert_eq!(stats.hits, 0);
1319        assert_eq!(stats.misses, 0);
1320        assert_eq!(stats.rebuilds, 0);
1321        assert_eq!(stats.hit_rate(), 0.0);
1322    }
1323
1324    #[test]
1325    fn cache_stats_debug_copy() {
1326        let stats = CacheStats {
1327            hits: 10,
1328            misses: 5,
1329            rebuilds: 1,
1330        };
1331        let dbg = format!("{:?}", stats);
1332        assert!(dbg.contains("CacheStats"), "Debug: {dbg}");
1333        let copy = stats; // Copy
1334        assert_eq!(copy.hits, stats.hits);
1335    }
1336
1337    #[test]
1338    fn cache_stats_100_percent_hit_rate() {
1339        let stats = CacheStats {
1340            hits: 100,
1341            misses: 0,
1342            rebuilds: 0,
1343        };
1344        assert!((stats.hit_rate() - 100.0).abs() < 0.01);
1345    }
1346
1347    #[test]
1348    fn cache_stats_0_percent_hit_rate() {
1349        let stats = CacheStats {
1350            hits: 0,
1351            misses: 100,
1352            rebuilds: 0,
1353        };
1354        assert!((stats.hit_rate()).abs() < 0.01);
1355    }
1356
1357    // --- SpatialHitIndex construction ---
1358
1359    #[test]
1360    fn new_with_cell_size_zero_clamped_to_one() {
1361        let config = SpatialHitConfig {
1362            cell_size: 0,
1363            ..Default::default()
1364        };
1365        let idx = SpatialHitIndex::new(80, 24, config);
1366        // cell_size=0 clamped to 1, grid = 80x24 buckets
1367        assert_eq!(idx.grid_width, 80);
1368        assert_eq!(idx.grid_height, 24);
1369        assert!(idx.is_empty());
1370    }
1371
1372    #[test]
1373    fn new_with_cell_size_one() {
1374        let config = SpatialHitConfig {
1375            cell_size: 1,
1376            ..Default::default()
1377        };
1378        let idx = SpatialHitIndex::new(10, 5, config);
1379        // 1 bucket per cell
1380        assert_eq!(idx.grid_width, 10);
1381        assert_eq!(idx.grid_height, 5);
1382    }
1383
1384    #[test]
1385    fn new_with_large_cell_size() {
1386        let config = SpatialHitConfig {
1387            cell_size: 100,
1388            ..Default::default()
1389        };
1390        let idx = SpatialHitIndex::new(80, 24, config);
1391        // 80/100 rounds up to 1, 24/100 rounds up to 1
1392        assert_eq!(idx.grid_width, 1);
1393        assert_eq!(idx.grid_height, 1);
1394    }
1395
1396    #[test]
1397    fn new_zero_dimensions() {
1398        let idx = SpatialHitIndex::with_defaults(0, 0);
1399        assert!(idx.is_empty());
1400        // All hit tests should return None
1401        assert!(idx.hit_test_readonly(0, 0).is_none());
1402    }
1403
1404    #[test]
1405    fn with_defaults_uses_default_config() {
1406        let idx = SpatialHitIndex::with_defaults(80, 24);
1407        assert_eq!(idx.config.cell_size, 8);
1408        assert_eq!(idx.config.bucket_warn_threshold, 64);
1409        assert!(!idx.config.track_cache_stats);
1410    }
1411
1412    #[test]
1413    fn index_debug_format() {
1414        let idx = SpatialHitIndex::with_defaults(10, 10);
1415        let dbg = format!("{:?}", idx);
1416        assert!(dbg.contains("SpatialHitIndex"), "Debug: {dbg}");
1417    }
1418
1419    // --- Register edge cases ---
1420
1421    #[test]
1422    fn register_zero_width_rect_not_in_buckets() {
1423        let mut idx = index();
1424        idx.register_simple(HitId::new(1), Rect::new(5, 5, 0, 10), HitRegion::Content, 0);
1425        // Still registered (len=1) but won't be found by hit_test
1426        assert_eq!(idx.len(), 1);
1427        assert!(idx.hit_test(5, 5).is_none());
1428    }
1429
1430    #[test]
1431    fn register_zero_height_rect_not_in_buckets() {
1432        let mut idx = index();
1433        idx.register_simple(HitId::new(1), Rect::new(5, 5, 10, 0), HitRegion::Content, 0);
1434        assert_eq!(idx.len(), 1);
1435        assert!(idx.hit_test(5, 5).is_none());
1436    }
1437
1438    #[test]
1439    fn register_rect_extending_past_screen() {
1440        let mut idx = index();
1441        // Rect extends past 80x24 screen
1442        idx.register_simple(
1443            HitId::new(1),
1444            Rect::new(70, 20, 20, 10),
1445            HitRegion::Content,
1446            0,
1447        );
1448        // Should still hit within screen bounds
1449        assert!(idx.hit_test(75, 22).is_some());
1450        // Outside screen returns None
1451        assert!(idx.hit_test(85, 25).is_none());
1452    }
1453
1454    #[test]
1455    fn register_many_widgets() {
1456        let mut idx = index();
1457        for i in 0..100u32 {
1458            let x = (i % 8) as u16 * 10;
1459            let y = (i / 8) as u16 * 3;
1460            idx.register_simple(
1461                HitId::new(i + 1),
1462                Rect::new(x, y, 5, 2),
1463                HitRegion::Content,
1464                i as u64,
1465            );
1466        }
1467        assert_eq!(idx.len(), 100);
1468        // Spot check
1469        let result = idx.hit_test(2, 1);
1470        assert!(result.is_some());
1471    }
1472
1473    #[test]
1474    fn register_simple_uses_z_order_zero() {
1475        let mut idx = index();
1476        idx.register_simple(
1477            HitId::new(1),
1478            Rect::new(0, 0, 10, 10),
1479            HitRegion::Content,
1480            0,
1481        );
1482        // Register with explicit z=1 in same area
1483        idx.register(
1484            HitId::new(2),
1485            Rect::new(0, 0, 10, 10),
1486            HitRegion::Border,
1487            0,
1488            1,
1489        );
1490        // Widget 2 should win (z=1 > z=0)
1491        let result = idx.hit_test(5, 5);
1492        assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 0)));
1493    }
1494
1495    // --- Update edge cases ---
1496
1497    #[test]
1498    fn update_to_zero_size_rect() {
1499        let mut idx = index();
1500        idx.register_simple(
1501            HitId::new(1),
1502            Rect::new(0, 0, 10, 10),
1503            HitRegion::Content,
1504            0,
1505        );
1506        assert!(idx.hit_test(5, 5).is_some());
1507
1508        idx.update(HitId::new(1), Rect::new(0, 0, 0, 0));
1509        // Zero-size rect won't be in buckets
1510        assert!(idx.hit_test(0, 0).is_none());
1511    }
1512
1513    #[test]
1514    fn update_shrinks_widget() {
1515        let mut idx = index();
1516        idx.register_simple(
1517            HitId::new(1),
1518            Rect::new(0, 0, 20, 20),
1519            HitRegion::Content,
1520            0,
1521        );
1522        assert!(idx.hit_test(15, 15).is_some());
1523
1524        idx.update(HitId::new(1), Rect::new(0, 0, 5, 5));
1525        assert!(idx.hit_test(15, 15).is_none());
1526        assert!(idx.hit_test(2, 2).is_some());
1527    }
1528
1529    // --- Remove edge cases ---
1530
1531    #[test]
1532    fn remove_middle_entry_compacts() {
1533        let mut idx = index();
1534        idx.register_simple(HitId::new(1), Rect::new(0, 0, 5, 5), HitRegion::Content, 10);
1535        idx.register_simple(
1536            HitId::new(2),
1537            Rect::new(10, 0, 5, 5),
1538            HitRegion::Content,
1539            20,
1540        );
1541        idx.register_simple(
1542            HitId::new(3),
1543            Rect::new(20, 0, 5, 5),
1544            HitRegion::Content,
1545            30,
1546        );
1547        assert_eq!(idx.len(), 3);
1548
1549        idx.remove(HitId::new(2));
1550        assert_eq!(idx.len(), 2);
1551
1552        // Widget 1 and 3 should still work
1553        let r1 = idx.hit_test(2, 2);
1554        assert_eq!(r1, Some((HitId::new(1), HitRegion::Content, 10)));
1555        let r3 = idx.hit_test(22, 2);
1556        assert_eq!(r3, Some((HitId::new(3), HitRegion::Content, 30)));
1557    }
1558
1559    #[test]
1560    fn double_remove_returns_false() {
1561        let mut idx = index();
1562        idx.register_simple(
1563            HitId::new(1),
1564            Rect::new(0, 0, 10, 10),
1565            HitRegion::Content,
1566            0,
1567        );
1568        assert!(idx.remove(HitId::new(1)));
1569        assert!(!idx.remove(HitId::new(1)));
1570    }
1571
1572    // --- hit_test edge cases ---
1573
1574    #[test]
1575    fn hit_test_at_exact_screen_boundary() {
1576        let mut idx = index(); // 80x24
1577        idx.register_simple(
1578            HitId::new(1),
1579            Rect::new(70, 20, 10, 4),
1580            HitRegion::Content,
1581            0,
1582        );
1583        // Last valid pixel
1584        assert!(idx.hit_test(79, 23).is_some());
1585        // One past screen
1586        assert!(idx.hit_test(80, 23).is_none());
1587        assert!(idx.hit_test(79, 24).is_none());
1588    }
1589
1590    #[test]
1591    fn hit_test_at_grid_cell_boundaries() {
1592        let mut idx = index(); // cell_size=8
1593        idx.register_simple(
1594            HitId::new(1),
1595            Rect::new(6, 6, 4, 4), // spans cells (0,0) and (1,1)
1596            HitRegion::Content,
1597            0,
1598        );
1599        // At x=7,y=7 (cell 0,0) - should hit
1600        assert!(idx.hit_test(7, 7).is_some());
1601        // At x=8,y=8 (cell 1,1) - should hit
1602        assert!(idx.hit_test(8, 8).is_some());
1603        // At x=9,y=9 (cell 1,1) - should hit
1604        assert!(idx.hit_test(9, 9).is_some());
1605        // At x=10,y=10 (cell 1,1) - outside rect
1606        assert!(idx.hit_test(10, 10).is_none());
1607    }
1608
1609    #[test]
1610    fn hit_test_readonly_out_of_bounds() {
1611        let idx = index();
1612        assert!(idx.hit_test_readonly(80, 0).is_none());
1613        assert!(idx.hit_test_readonly(0, 24).is_none());
1614        assert!(idx.hit_test_readonly(u16::MAX, u16::MAX).is_none());
1615    }
1616
1617    #[test]
1618    fn hit_test_readonly_skips_removed() {
1619        let mut idx = index();
1620        idx.register_simple(
1621            HitId::new(1),
1622            Rect::new(0, 0, 10, 10),
1623            HitRegion::Content,
1624            0,
1625        );
1626        idx.register_simple(HitId::new(2), Rect::new(0, 0, 10, 10), HitRegion::Border, 1);
1627        idx.remove(HitId::new(2));
1628        // Widget 1 should still be found
1629        let result = idx.hit_test_readonly(5, 5);
1630        assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 0)));
1631    }
1632
1633    // --- Cache behavior ---
1634
1635    #[test]
1636    fn cache_updates_on_different_positions() {
1637        let mut idx = SpatialHitIndex::new(
1638            80,
1639            24,
1640            SpatialHitConfig {
1641                track_cache_stats: true,
1642                ..Default::default()
1643            },
1644        );
1645        idx.register_simple(
1646            HitId::new(1),
1647            Rect::new(0, 0, 40, 12),
1648            HitRegion::Content,
1649            1,
1650        );
1651        idx.register_simple(
1652            HitId::new(2),
1653            Rect::new(40, 12, 40, 12),
1654            HitRegion::Border,
1655            2,
1656        );
1657
1658        // First query
1659        let r1 = idx.hit_test(5, 5);
1660        assert_eq!(r1, Some((HitId::new(1), HitRegion::Content, 1)));
1661        assert_eq!(idx.stats().misses, 1);
1662
1663        // Different position - miss
1664        let r2 = idx.hit_test(50, 15);
1665        assert_eq!(r2, Some((HitId::new(2), HitRegion::Border, 2)));
1666        assert_eq!(idx.stats().misses, 2);
1667
1668        // Back to first position - miss (cache only stores one position)
1669        let _ = idx.hit_test(5, 5);
1670        assert_eq!(idx.stats().misses, 3);
1671    }
1672
1673    #[test]
1674    fn cache_invalidated_by_invalidate_all_then_same_position() {
1675        let mut idx = SpatialHitIndex::new(
1676            80,
1677            24,
1678            SpatialHitConfig {
1679                track_cache_stats: true,
1680                ..Default::default()
1681            },
1682        );
1683        idx.register_simple(
1684            HitId::new(1),
1685            Rect::new(0, 0, 10, 10),
1686            HitRegion::Content,
1687            0,
1688        );
1689
1690        // Prime cache
1691        let _ = idx.hit_test(5, 5);
1692        assert_eq!(idx.stats().misses, 1);
1693        assert_eq!(idx.stats().hits, 0);
1694
1695        // Invalidate all then query same position
1696        idx.invalidate_all();
1697        let _ = idx.hit_test(5, 5);
1698        // Should be a miss since cache was invalidated
1699        assert_eq!(idx.stats().misses, 2);
1700    }
1701
1702    #[test]
1703    fn cache_not_updated_by_readonly() {
1704        let mut idx = SpatialHitIndex::new(
1705            80,
1706            24,
1707            SpatialHitConfig {
1708                track_cache_stats: true,
1709                ..Default::default()
1710            },
1711        );
1712        idx.register_simple(
1713            HitId::new(1),
1714            Rect::new(0, 0, 10, 10),
1715            HitRegion::Content,
1716            0,
1717        );
1718
1719        // readonly doesn't update cache
1720        let _ = idx.hit_test_readonly(5, 5);
1721        assert_eq!(idx.stats().hits, 0);
1722        assert_eq!(idx.stats().misses, 0);
1723
1724        // mutable query at same position should be a miss
1725        let _ = idx.hit_test(5, 5);
1726        assert_eq!(idx.stats().misses, 1);
1727    }
1728
1729    // --- Invalidation edge cases ---
1730
1731    #[test]
1732    fn invalidate_region_zero_size() {
1733        let mut idx = index();
1734        idx.register_simple(
1735            HitId::new(1),
1736            Rect::new(0, 0, 10, 10),
1737            HitRegion::Content,
1738            0,
1739        );
1740        let _ = idx.hit_test(5, 5);
1741        assert!(idx.cache.valid);
1742
1743        // Zero-size rect shouldn't invalidate anything
1744        idx.invalidate_region(Rect::new(5, 5, 0, 0));
1745        assert!(idx.cache.valid);
1746    }
1747
1748    #[test]
1749    fn invalidate_region_outside_screen() {
1750        let mut idx = index();
1751        idx.register_simple(
1752            HitId::new(1),
1753            Rect::new(0, 0, 10, 10),
1754            HitRegion::Content,
1755            0,
1756        );
1757        let _ = idx.hit_test(5, 5);
1758        assert!(idx.cache.valid);
1759
1760        // Region outside screen
1761        idx.invalidate_region(Rect::new(100, 100, 10, 10));
1762        // Cache position (5,5) is not in the dirty region, so cache stays valid
1763        assert!(idx.cache.valid);
1764    }
1765
1766    // --- Rebuild tracking ---
1767
1768    #[test]
1769    fn rebuild_counted_in_stats() {
1770        let mut idx = SpatialHitIndex::new(
1771            80,
1772            24,
1773            SpatialHitConfig {
1774                track_cache_stats: true,
1775                ..Default::default()
1776            },
1777        );
1778        idx.register_simple(
1779            HitId::new(1),
1780            Rect::new(0, 0, 10, 10),
1781            HitRegion::Content,
1782            0,
1783        );
1784        assert_eq!(idx.stats().rebuilds, 0);
1785
1786        // Update triggers rebuild
1787        idx.update(HitId::new(1), Rect::new(10, 10, 5, 5));
1788        assert_eq!(idx.stats().rebuilds, 1);
1789
1790        // Remove triggers rebuild
1791        idx.remove(HitId::new(1));
1792        assert_eq!(idx.stats().rebuilds, 2);
1793    }
1794
1795    // --- Full lifecycle ---
1796
1797    #[test]
1798    fn register_hit_update_hit_remove_clear() {
1799        let mut idx = index();
1800
1801        // Register
1802        idx.register_simple(
1803            HitId::new(1),
1804            Rect::new(0, 0, 10, 10),
1805            HitRegion::Content,
1806            0,
1807        );
1808        assert_eq!(idx.len(), 1);
1809
1810        // Hit
1811        assert!(idx.hit_test(5, 5).is_some());
1812
1813        // Update
1814        idx.update(HitId::new(1), Rect::new(20, 20, 10, 10));
1815        assert!(idx.hit_test(5, 5).is_none());
1816        assert!(idx.hit_test(25, 22).is_some());
1817
1818        // Remove
1819        idx.remove(HitId::new(1));
1820        assert!(idx.is_empty());
1821        assert!(idx.hit_test(25, 22).is_none());
1822
1823        // Re-register
1824        idx.register_simple(HitId::new(2), Rect::new(0, 0, 5, 5), HitRegion::Button, 99);
1825        assert_eq!(idx.len(), 1);
1826        let r = idx.hit_test(2, 2);
1827        assert_eq!(r, Some((HitId::new(2), HitRegion::Button, 99)));
1828
1829        // Clear
1830        idx.clear();
1831        assert!(idx.is_empty());
1832        assert!(idx.hit_test(2, 2).is_none());
1833    }
1834
1835    // --- Z-order tie-breaking ---
1836
1837    #[test]
1838    fn z_order_tie_broken_by_registration_order() {
1839        let mut idx = index();
1840        // Same z_order=5, different registration order
1841        idx.register(
1842            HitId::new(1),
1843            Rect::new(0, 0, 10, 10),
1844            HitRegion::Content,
1845            10,
1846            5,
1847        );
1848        idx.register(
1849            HitId::new(2),
1850            Rect::new(0, 0, 10, 10),
1851            HitRegion::Border,
1852            20,
1853            5,
1854        );
1855        idx.register(
1856            HitId::new(3),
1857            Rect::new(0, 0, 10, 10),
1858            HitRegion::Button,
1859            30,
1860            5,
1861        );
1862
1863        // Widget 3 wins (latest registration at same z)
1864        let result = idx.hit_test(5, 5);
1865        assert_eq!(result, Some((HitId::new(3), HitRegion::Button, 30)));
1866    }
1867
1868    #[test]
1869    fn z_order_higher_z_beats_later_registration() {
1870        let mut idx = index();
1871        // Widget 1: z=10, registered first
1872        idx.register(
1873            HitId::new(1),
1874            Rect::new(0, 0, 10, 10),
1875            HitRegion::Content,
1876            10,
1877            10,
1878        );
1879        // Widget 2: z=5, registered later
1880        idx.register(
1881            HitId::new(2),
1882            Rect::new(0, 0, 10, 10),
1883            HitRegion::Border,
1884            20,
1885            5,
1886        );
1887
1888        // Widget 1 wins (higher z trumps registration order)
1889        let result = idx.hit_test(5, 5);
1890        assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 10)));
1891    }
1892
1893    // --- HitRegion variants in hit_test ---
1894
1895    #[test]
1896    fn all_hit_region_variants_returned() {
1897        let mut idx = index();
1898        let regions = [
1899            (1, HitRegion::Content),
1900            (2, HitRegion::Border),
1901            (3, HitRegion::Scrollbar),
1902            (4, HitRegion::Handle),
1903            (5, HitRegion::Button),
1904            (6, HitRegion::Link),
1905            (7, HitRegion::Custom(42)),
1906        ];
1907        for (i, (id, region)) in regions.iter().enumerate() {
1908            let x = (i as u16) * 10;
1909            idx.register_simple(HitId::new(*id), Rect::new(x, 0, 5, 5), *region, *id as u64);
1910        }
1911        for (i, (id, region)) in regions.iter().enumerate() {
1912            let x = (i as u16) * 10 + 2;
1913            let result = idx.hit_test(x, 2);
1914            assert_eq!(
1915                result,
1916                Some((HitId::new(*id), *region, *id as u64)),
1917                "Failed for region {:?}",
1918                region
1919            );
1920        }
1921    }
1922
1923    // --- Width=1 and Height=1 edge ---
1924
1925    #[test]
1926    fn single_cell_screen() {
1927        let mut idx = SpatialHitIndex::with_defaults(1, 1);
1928        idx.register_simple(HitId::new(1), Rect::new(0, 0, 1, 1), HitRegion::Content, 0);
1929        assert!(idx.hit_test(0, 0).is_some());
1930        assert!(idx.hit_test(1, 0).is_none());
1931    }
1932
1933    // --- Readonly equivalence across whole grid ---
1934
1935    #[test]
1936    fn hit_test_readonly_equivalent_to_mutable_for_grid() {
1937        let mut idx = index();
1938        idx.register(
1939            HitId::new(1),
1940            Rect::new(0, 0, 40, 12),
1941            HitRegion::Content,
1942            1,
1943            0,
1944        );
1945        idx.register(
1946            HitId::new(2),
1947            Rect::new(30, 8, 20, 10),
1948            HitRegion::Border,
1949            2,
1950            1,
1951        );
1952        idx.register(
1953            HitId::new(3),
1954            Rect::new(60, 0, 20, 24),
1955            HitRegion::Button,
1956            3,
1957            2,
1958        );
1959
1960        // Compare at grid of points
1961        for x in (0..80).step_by(5) {
1962            for y in (0..24).step_by(3) {
1963                let ro = idx.hit_test_readonly(x, y);
1964                let expected_id = ro.map(|(id, _, _)| id);
1965                // We can't use hit_test (mutates cache) in a fair comparison loop,
1966                // so just verify readonly is consistent with itself
1967                let ro2 = idx.hit_test_readonly(x, y);
1968                assert_eq!(ro, ro2, "Readonly inconsistency at ({x}, {y})");
1969                // Also verify against mutable
1970                let mut_result = idx.hit_test(x, y);
1971                let mut_id = mut_result.map(|(id, _, _)| id);
1972                assert_eq!(
1973                    expected_id, mut_id,
1974                    "Mutable/readonly mismatch at ({x}, {y})"
1975                );
1976            }
1977        }
1978    }
1979}