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