1#![forbid(unsafe_code)]
2
3use crate::frame::{HitData, HitId, HitRegion};
30use ahash::AHashMap;
31use ftui_core::geometry::Rect;
32
33#[derive(Debug, Clone)]
39pub struct SpatialHitConfig {
40 pub cell_size: u16,
43
44 pub bucket_warn_threshold: usize,
46
47 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct HitEntry {
68 pub id: HitId,
70 pub rect: Rect,
72 pub region: HitRegion,
74 pub data: HitData,
76 pub z_order: u16,
78 order: u32,
80}
81
82impl HitEntry {
83 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 #[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 #[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#[derive(Debug, Clone, Default)]
127struct Bucket {
128 entries: Vec<u32>,
130}
131
132impl Bucket {
133 #[inline]
135 fn push(&mut self, entry_idx: u32) {
136 self.entries.push(entry_idx);
137 }
138
139 #[inline]
141 fn clear(&mut self) {
142 self.entries.clear();
143 }
144}
145
146#[derive(Debug, Clone, Copy, Default)]
152struct HoverCache {
153 pos: (u16, u16),
155 result: Option<u32>,
157 valid: bool,
159}
160
161#[derive(Debug, Clone, Default)]
167struct DirtyTracker {
168 dirty_rects: Vec<Rect>,
170 full_rebuild: bool,
172}
173
174impl DirtyTracker {
175 fn mark_dirty(&mut self, rect: Rect) {
177 if !self.full_rebuild {
178 self.dirty_rects.push(rect);
179 }
180 }
181
182 fn mark_full_rebuild(&mut self) {
184 self.full_rebuild = true;
185 self.dirty_rects.clear();
186 }
187
188 fn clear(&mut self) {
190 self.dirty_rects.clear();
191 self.full_rebuild = false;
192 }
193
194 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#[derive(Debug, Clone, Copy, Default)]
218pub struct CacheStats {
219 pub hits: u64,
221 pub misses: u64,
223 pub rebuilds: u64,
225}
226
227impl CacheStats {
228 #[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#[derive(Debug)]
249pub struct SpatialHitIndex {
250 config: SpatialHitConfig,
251
252 width: u16,
254 height: u16,
255
256 grid_width: u16,
258 grid_height: u16,
259
260 entries: Vec<HitEntry>,
262
263 buckets: Vec<Bucket>,
265
266 next_order: u32,
268
269 cache: HoverCache,
271
272 dirty: DirtyTracker,
274
275 stats: CacheStats,
277
278 id_to_entry: AHashMap<HitId, u32>,
280}
281
282impl SpatialHitIndex {
283 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 pub fn with_defaults(width: u16, height: u16) -> Self {
308 Self::new(width, height, SpatialHitConfig::default())
309 }
310
311 pub fn register(
321 &mut self,
322 id: HitId,
323 rect: Rect,
324 region: HitRegion,
325 data: HitData,
326 z_order: u16,
327 ) {
328 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 self.add_to_buckets(entry_idx, rect);
338
339 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 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 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 self.dirty.mark_dirty(old_rect);
363 self.dirty.mark_dirty(new_rect);
364
365 self.entries[entry_idx as usize].rect = new_rect;
367
368 self.rebuild_buckets();
371
372 self.cache.valid = false;
374
375 true
376 }
377
378 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 self.entries[entry_idx as usize].id = HitId::default();
391 self.id_to_entry.remove(&id);
392
393 self.rebuild_buckets();
395 self.cache.valid = false;
396
397 true
398 }
399
400 #[must_use]
409 pub fn hit_test(&mut self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
410 if x >= self.width || y >= self.height {
412 return None;
413 }
414
415 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 let bucket_idx = self.bucket_index(x, y);
432 let bucket = &self.buckets[bucket_idx];
433
434 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 if entry.id == HitId::default() {
443 continue;
444 }
445
446 if entry.contains(x, y) {
448 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 self.cache.pos = (x, y);
465 self.cache.result = best_idx;
466 self.cache.valid = true;
467 self.dirty.clear();
469
470 best.map(|e| (e.id, e.region, e.data))
471 }
472
473 #[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 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 #[must_use]
518 pub fn stats(&self) -> CacheStats {
519 self.stats
520 }
521
522 pub fn reset_stats(&mut self) {
524 self.stats = CacheStats::default();
525 }
526
527 #[inline]
529 #[must_use]
530 pub fn len(&self) -> usize {
531 self.id_to_entry.len()
532 }
533
534 #[inline]
536 #[must_use]
537 pub fn is_empty(&self) -> bool {
538 self.id_to_entry.is_empty()
539 }
540
541 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 pub fn invalidate_all(&mut self) {
551 self.cache.valid = false;
552 self.dirty.mark_full_rebuild();
553 }
554
555 #[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 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 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 if self.buckets[bucket_idx].entries.len() > self.config.bucket_warn_threshold {
599 }
601 }
602 }
603 }
604 }
605
606 fn rebuild_buckets(&mut self) {
608 for bucket in &mut self.buckets {
610 bucket.clear();
611 }
612
613 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 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 let entry_rects: Vec<(u32, Rect)> = self
633 .entries
634 .iter()
635 .enumerate()
636 .map(|(idx, e)| (idx as u32, e.rect))
637 .collect();
638 for (idx, rect) in entry_rects {
639 self.add_to_buckets_internal(idx, rect);
640 }
641
642 self.dirty.clear();
643 self.stats.rebuilds += 1;
644 }
645
646 fn add_to_buckets_internal(&mut self, entry_idx: u32, rect: Rect) {
648 if rect.width == 0 || rect.height == 0 {
649 return;
650 }
651
652 let (bx_start, by_start, bx_end, by_end) = self.bucket_range(rect);
653
654 for by in by_start..=by_end {
655 for bx in bx_start..=bx_end {
656 let bucket_idx = by as usize * self.grid_width as usize + bx as usize;
657 if bucket_idx < self.buckets.len() {
658 self.buckets[bucket_idx].push(entry_idx);
659 }
660 }
661 }
662 }
663}
664
665#[cfg(test)]
670mod tests {
671 use super::*;
672
673 fn index() -> SpatialHitIndex {
674 SpatialHitIndex::with_defaults(80, 24)
675 }
676
677 #[test]
680 fn initial_state_empty() {
681 let idx = index();
682 assert!(idx.is_empty());
683 assert_eq!(idx.len(), 0);
684 }
685
686 #[test]
687 fn register_and_hit_test() {
688 let mut idx = index();
689 idx.register_simple(
690 HitId::new(1),
691 Rect::new(10, 5, 20, 3),
692 HitRegion::Button,
693 42,
694 );
695
696 let result = idx.hit_test(15, 6);
698 assert_eq!(result, Some((HitId::new(1), HitRegion::Button, 42)));
699
700 assert!(idx.hit_test(5, 5).is_none());
702 assert!(idx.hit_test(35, 5).is_none());
703 }
704
705 #[test]
706 fn z_order_topmost_wins() {
707 let mut idx = index();
708
709 idx.register(
711 HitId::new(1),
712 Rect::new(0, 0, 10, 10),
713 HitRegion::Content,
714 1,
715 0, );
717 idx.register(
718 HitId::new(2),
719 Rect::new(5, 5, 10, 10),
720 HitRegion::Border,
721 2,
722 1, );
724
725 let result = idx.hit_test(7, 7);
727 assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
728
729 let result = idx.hit_test(2, 2);
731 assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 1)));
732 }
733
734 #[test]
735 fn same_z_order_later_wins() {
736 let mut idx = index();
737
738 idx.register(
740 HitId::new(1),
741 Rect::new(0, 0, 10, 10),
742 HitRegion::Content,
743 1,
744 0,
745 );
746 idx.register(
747 HitId::new(2),
748 Rect::new(5, 5, 10, 10),
749 HitRegion::Border,
750 2,
751 0,
752 );
753
754 let result = idx.hit_test(7, 7);
756 assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
757 }
758
759 #[test]
760 fn hit_test_border_inclusive() {
761 let mut idx = index();
762 idx.register_simple(
763 HitId::new(1),
764 Rect::new(10, 10, 5, 5),
765 HitRegion::Content,
766 0,
767 );
768
769 assert!(idx.hit_test(10, 10).is_some()); assert!(idx.hit_test(14, 10).is_some()); assert!(idx.hit_test(10, 14).is_some()); assert!(idx.hit_test(14, 14).is_some()); assert!(idx.hit_test(15, 10).is_none()); assert!(idx.hit_test(10, 15).is_none()); assert!(idx.hit_test(9, 10).is_none()); assert!(idx.hit_test(10, 9).is_none()); }
781
782 #[test]
783 fn update_widget_rect() {
784 let mut idx = index();
785 idx.register_simple(
786 HitId::new(1),
787 Rect::new(0, 0, 10, 10),
788 HitRegion::Content,
789 0,
790 );
791
792 assert!(idx.hit_test(5, 5).is_some());
794
795 let updated = idx.update(HitId::new(1), Rect::new(50, 10, 10, 10));
797 assert!(updated);
798
799 assert!(idx.hit_test(5, 5).is_none());
801
802 assert!(idx.hit_test(55, 15).is_some());
804 }
805
806 #[test]
807 fn remove_widget() {
808 let mut idx = index();
809 idx.register_simple(
810 HitId::new(1),
811 Rect::new(0, 0, 10, 10),
812 HitRegion::Content,
813 0,
814 );
815
816 assert!(idx.hit_test(5, 5).is_some());
817
818 let removed = idx.remove(HitId::new(1));
819 assert!(removed);
820
821 assert!(idx.hit_test(5, 5).is_none());
822 assert!(idx.is_empty());
823 }
824
825 #[test]
826 fn clear_all() {
827 let mut idx = index();
828 idx.register_simple(
829 HitId::new(1),
830 Rect::new(0, 0, 10, 10),
831 HitRegion::Content,
832 0,
833 );
834 idx.register_simple(
835 HitId::new(2),
836 Rect::new(20, 20, 10, 10),
837 HitRegion::Button,
838 1,
839 );
840
841 assert_eq!(idx.len(), 2);
842
843 idx.clear();
844
845 assert!(idx.is_empty());
846 assert!(idx.hit_test(5, 5).is_none());
847 assert!(idx.hit_test(25, 25).is_none());
848 }
849
850 #[test]
853 fn cache_hit_on_same_position() {
854 let mut idx = SpatialHitIndex::new(
855 80,
856 24,
857 SpatialHitConfig {
858 track_cache_stats: true,
859 ..Default::default()
860 },
861 );
862 idx.register_simple(
863 HitId::new(1),
864 Rect::new(0, 0, 10, 10),
865 HitRegion::Content,
866 0,
867 );
868
869 let _ = idx.hit_test(5, 5);
871 assert_eq!(idx.stats().misses, 1);
872 assert_eq!(idx.stats().hits, 0);
873
874 let _ = idx.hit_test(5, 5);
876 assert_eq!(idx.stats().hits, 1);
877
878 let _ = idx.hit_test(7, 7);
880 assert_eq!(idx.stats().misses, 2);
881 }
882
883 #[test]
884 fn cache_invalidated_on_register() {
885 let mut idx = SpatialHitIndex::new(
886 80,
887 24,
888 SpatialHitConfig {
889 track_cache_stats: true,
890 ..Default::default()
891 },
892 );
893 idx.register_simple(
894 HitId::new(1),
895 Rect::new(0, 0, 10, 10),
896 HitRegion::Content,
897 0,
898 );
899
900 let _ = idx.hit_test(5, 5);
902
903 idx.register_simple(HitId::new(2), Rect::new(0, 0, 10, 10), HitRegion::Button, 1);
905
906 let hits_before = idx.stats().hits;
908 let _ = idx.hit_test(5, 5);
909 assert_eq!(idx.stats().hits, hits_before);
911 }
912
913 #[test]
916 fn property_random_layout_correctness() {
917 let mut idx = index();
918 let widgets = vec![
919 (HitId::new(1), Rect::new(0, 0, 20, 10), 0u16),
920 (HitId::new(2), Rect::new(10, 5, 20, 10), 1),
921 (HitId::new(3), Rect::new(25, 0, 15, 15), 2),
922 ];
923
924 for (id, rect, z) in &widgets {
925 idx.register(*id, *rect, HitRegion::Content, id.id() as u64, *z);
926 }
927
928 for x in 0..60 {
930 for y in 0..20 {
931 let indexed_result = idx.hit_test_readonly(x, y);
932
933 let mut best: Option<(HitId, u16)> = None;
935 for (id, rect, z) in &widgets {
936 if x >= rect.x
937 && x < rect.x + rect.width
938 && y >= rect.y
939 && y < rect.y + rect.height
940 {
941 match best {
942 None => best = Some((*id, *z)),
943 Some((_, best_z)) if *z > best_z => best = Some((*id, *z)),
944 _ => {}
945 }
946 }
947 }
948
949 let expected_id = best.map(|(id, _)| id);
950 let indexed_id = indexed_result.map(|(id, _, _)| id);
951
952 assert_eq!(
953 indexed_id, expected_id,
954 "Mismatch at ({}, {}): indexed={:?}, expected={:?}",
955 x, y, indexed_id, expected_id
956 );
957 }
958 }
959 }
960
961 #[test]
964 fn out_of_bounds_returns_none() {
965 let mut idx = index();
966 idx.register_simple(
967 HitId::new(1),
968 Rect::new(0, 0, 10, 10),
969 HitRegion::Content,
970 0,
971 );
972
973 assert!(idx.hit_test(100, 100).is_none());
974 assert!(idx.hit_test(80, 0).is_none());
975 assert!(idx.hit_test(0, 24).is_none());
976 }
977
978 #[test]
979 fn zero_size_rect_ignored() {
980 let mut idx = index();
981 idx.register_simple(
982 HitId::new(1),
983 Rect::new(10, 10, 0, 0),
984 HitRegion::Content,
985 0,
986 );
987
988 assert!(idx.hit_test(10, 10).is_none());
990 }
991
992 #[test]
993 fn large_rect_spans_many_buckets() {
994 let mut idx = index();
995 idx.register_simple(
997 HitId::new(1),
998 Rect::new(0, 0, 80, 24),
999 HitRegion::Content,
1000 0,
1001 );
1002
1003 assert!(idx.hit_test(0, 0).is_some());
1005 assert!(idx.hit_test(40, 12).is_some());
1006 assert!(idx.hit_test(79, 23).is_some());
1007 }
1008
1009 #[test]
1010 fn update_nonexistent_returns_false() {
1011 let mut idx = index();
1012 let result = idx.update(HitId::new(999), Rect::new(0, 0, 10, 10));
1013 assert!(!result);
1014 }
1015
1016 #[test]
1017 fn remove_nonexistent_returns_false() {
1018 let mut idx = index();
1019 let result = idx.remove(HitId::new(999));
1020 assert!(!result);
1021 }
1022
1023 #[test]
1024 fn stats_hit_rate() {
1025 let mut stats = CacheStats::default();
1026 assert_eq!(stats.hit_rate(), 0.0);
1027
1028 stats.hits = 75;
1029 stats.misses = 25;
1030 assert!((stats.hit_rate() - 75.0).abs() < 0.01);
1031 }
1032
1033 #[test]
1034 fn config_defaults() {
1035 let config = SpatialHitConfig::default();
1036 assert_eq!(config.cell_size, 8);
1037 assert_eq!(config.bucket_warn_threshold, 64);
1038 assert!(!config.track_cache_stats);
1039 }
1040
1041 #[test]
1042 fn invalidate_region() {
1043 let mut idx = index();
1044 idx.register_simple(
1045 HitId::new(1),
1046 Rect::new(0, 0, 10, 10),
1047 HitRegion::Content,
1048 0,
1049 );
1050
1051 let _ = idx.hit_test(5, 5);
1053 assert!(idx.cache.valid);
1054
1055 idx.invalidate_region(Rect::new(0, 0, 10, 10));
1057 assert!(!idx.cache.valid);
1058 }
1059
1060 #[test]
1061 fn invalidate_all() {
1062 let mut idx = index();
1063 idx.register_simple(
1064 HitId::new(1),
1065 Rect::new(0, 0, 10, 10),
1066 HitRegion::Content,
1067 0,
1068 );
1069
1070 let _ = idx.hit_test(5, 5);
1071 assert!(idx.cache.valid);
1072
1073 idx.invalidate_all();
1074 assert!(!idx.cache.valid);
1075 }
1076
1077 #[test]
1078 fn three_overlapping_widgets_z_order() {
1079 let mut idx = index();
1080 idx.register(
1081 HitId::new(1),
1082 Rect::new(0, 0, 20, 20),
1083 HitRegion::Content,
1084 10,
1085 0,
1086 );
1087 idx.register(
1088 HitId::new(2),
1089 Rect::new(5, 5, 15, 15),
1090 HitRegion::Border,
1091 20,
1092 2,
1093 );
1094 idx.register(
1095 HitId::new(3),
1096 Rect::new(8, 8, 10, 10),
1097 HitRegion::Button,
1098 30,
1099 1,
1100 );
1101 let result = idx.hit_test(10, 10);
1103 assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 20)));
1104 }
1105
1106 #[test]
1107 fn hit_test_readonly_matches_mutable() {
1108 let mut idx = index();
1109 idx.register_simple(
1110 HitId::new(1),
1111 Rect::new(5, 5, 10, 10),
1112 HitRegion::Content,
1113 0,
1114 );
1115 let mutable_result = idx.hit_test(8, 8);
1116 let readonly_result = idx.hit_test_readonly(8, 8);
1117 assert_eq!(mutable_result, readonly_result);
1118 }
1119
1120 #[test]
1121 fn single_pixel_widget() {
1122 let mut idx = index();
1123 idx.register_simple(HitId::new(1), Rect::new(5, 5, 1, 1), HitRegion::Button, 0);
1124 assert!(idx.hit_test(5, 5).is_some());
1125 assert!(idx.hit_test(6, 5).is_none());
1126 assert!(idx.hit_test(5, 6).is_none());
1127 }
1128
1129 #[test]
1130 fn clear_on_empty_is_idempotent() {
1131 let mut idx = index();
1132 idx.clear();
1133 assert!(idx.is_empty());
1134 idx.clear();
1135 assert!(idx.is_empty());
1136 }
1137
1138 #[test]
1139 fn register_remove_register_cycle() {
1140 let mut idx = index();
1141 idx.register_simple(
1142 HitId::new(1),
1143 Rect::new(0, 0, 10, 10),
1144 HitRegion::Content,
1145 0,
1146 );
1147 assert_eq!(idx.len(), 1);
1148 idx.remove(HitId::new(1));
1149 assert_eq!(idx.len(), 0);
1150 idx.register_simple(HitId::new(1), Rect::new(20, 20, 5, 5), HitRegion::Border, 0);
1151 assert_eq!(idx.len(), 1);
1152 assert!(idx.hit_test(22, 22).is_some());
1154 assert!(idx.hit_test(5, 5).is_none());
1155 }
1156
1157 #[test]
1158 fn invalidate_non_overlapping_region_preserves_cache() {
1159 let mut idx = index();
1160 idx.register_simple(
1161 HitId::new(1),
1162 Rect::new(0, 0, 10, 10),
1163 HitRegion::Content,
1164 0,
1165 );
1166 let _ = idx.hit_test(5, 5);
1167 assert!(idx.cache.valid);
1168 idx.invalidate_region(Rect::new(50, 50, 10, 10));
1170 assert!(idx.cache.valid);
1171 }
1172
1173 #[test]
1174 fn hit_entry_contains() {
1175 let entry = HitEntry::new(
1176 HitId::new(1),
1177 Rect::new(10, 10, 20, 20),
1178 HitRegion::Content,
1179 0,
1180 0,
1181 0,
1182 );
1183 assert!(entry.contains(15, 15));
1184 assert!(entry.contains(10, 10));
1185 assert!(!entry.contains(9, 10));
1186 assert!(!entry.contains(30, 30));
1187 }
1188
1189 #[test]
1190 fn reset_stats_clears_counters() {
1191 let mut idx = SpatialHitIndex::new(
1192 80,
1193 24,
1194 SpatialHitConfig {
1195 cell_size: 8,
1196 bucket_warn_threshold: 64,
1197 track_cache_stats: true,
1198 },
1199 );
1200 idx.register_simple(
1201 HitId::new(1),
1202 Rect::new(0, 0, 10, 10),
1203 HitRegion::Content,
1204 0,
1205 );
1206 let _ = idx.hit_test(5, 5);
1207 let _ = idx.hit_test(5, 5); let stats = idx.stats();
1209 assert!(stats.hits > 0 || stats.misses > 0);
1210 idx.reset_stats();
1211 let stats = idx.stats();
1212 assert_eq!(stats.hits, 0);
1213 assert_eq!(stats.misses, 0);
1214 }
1215
1216 #[test]
1223 fn config_debug_clone() {
1224 let config = SpatialHitConfig::default();
1225 let dbg = format!("{:?}", config);
1226 assert!(dbg.contains("SpatialHitConfig"), "Debug: {dbg}");
1227 let cloned = config.clone();
1228 assert_eq!(cloned.cell_size, 8);
1229 }
1230
1231 #[test]
1234 fn hit_entry_debug_clone_copy_eq() {
1235 let entry = HitEntry::new(
1236 HitId::new(1),
1237 Rect::new(0, 0, 10, 10),
1238 HitRegion::Content,
1239 42,
1240 5,
1241 0,
1242 );
1243 let dbg = format!("{:?}", entry);
1244 assert!(dbg.contains("HitEntry"), "Debug: {dbg}");
1245 let copied = entry; assert_eq!(entry, copied);
1247 let cloned: HitEntry = entry; assert_eq!(entry, cloned);
1249 }
1250
1251 #[test]
1252 fn hit_entry_ne() {
1253 let a = HitEntry::new(
1254 HitId::new(1),
1255 Rect::new(0, 0, 10, 10),
1256 HitRegion::Content,
1257 0,
1258 0,
1259 0,
1260 );
1261 let b = HitEntry::new(
1262 HitId::new(2),
1263 Rect::new(0, 0, 10, 10),
1264 HitRegion::Content,
1265 0,
1266 0,
1267 0,
1268 );
1269 assert_ne!(a, b);
1270 }
1271
1272 #[test]
1273 fn hit_entry_contains_zero_width() {
1274 let entry = HitEntry::new(
1275 HitId::new(1),
1276 Rect::new(10, 10, 0, 5),
1277 HitRegion::Content,
1278 0,
1279 0,
1280 0,
1281 );
1282 assert!(!entry.contains(10, 10));
1284 }
1285
1286 #[test]
1287 fn hit_entry_contains_zero_height() {
1288 let entry = HitEntry::new(
1289 HitId::new(1),
1290 Rect::new(10, 10, 5, 0),
1291 HitRegion::Content,
1292 0,
1293 0,
1294 0,
1295 );
1296 assert!(!entry.contains(10, 10));
1297 }
1298
1299 #[test]
1300 fn hit_entry_contains_at_saturating_boundary() {
1301 let entry = HitEntry::new(
1303 HitId::new(1),
1304 Rect::new(u16::MAX - 5, u16::MAX - 5, 10, 10),
1305 HitRegion::Content,
1306 0,
1307 0,
1308 0,
1309 );
1310 assert!(entry.contains(u16::MAX - 5, u16::MAX - 5));
1313 assert!(entry.contains(u16::MAX - 1, u16::MAX - 1));
1314 assert!(!entry.contains(u16::MAX, u16::MAX));
1315 }
1316
1317 #[test]
1320 fn cache_stats_default() {
1321 let stats = CacheStats::default();
1322 assert_eq!(stats.hits, 0);
1323 assert_eq!(stats.misses, 0);
1324 assert_eq!(stats.rebuilds, 0);
1325 assert_eq!(stats.hit_rate(), 0.0);
1326 }
1327
1328 #[test]
1329 fn cache_stats_debug_copy() {
1330 let stats = CacheStats {
1331 hits: 10,
1332 misses: 5,
1333 rebuilds: 1,
1334 };
1335 let dbg = format!("{:?}", stats);
1336 assert!(dbg.contains("CacheStats"), "Debug: {dbg}");
1337 let copy = stats; assert_eq!(copy.hits, stats.hits);
1339 }
1340
1341 #[test]
1342 fn cache_stats_100_percent_hit_rate() {
1343 let stats = CacheStats {
1344 hits: 100,
1345 misses: 0,
1346 rebuilds: 0,
1347 };
1348 assert!((stats.hit_rate() - 100.0).abs() < 0.01);
1349 }
1350
1351 #[test]
1352 fn cache_stats_0_percent_hit_rate() {
1353 let stats = CacheStats {
1354 hits: 0,
1355 misses: 100,
1356 rebuilds: 0,
1357 };
1358 assert!((stats.hit_rate()).abs() < 0.01);
1359 }
1360
1361 #[test]
1364 fn new_with_cell_size_zero_clamped_to_one() {
1365 let config = SpatialHitConfig {
1366 cell_size: 0,
1367 ..Default::default()
1368 };
1369 let idx = SpatialHitIndex::new(80, 24, config);
1370 assert_eq!(idx.grid_width, 80);
1372 assert_eq!(idx.grid_height, 24);
1373 assert!(idx.is_empty());
1374 }
1375
1376 #[test]
1377 fn new_with_cell_size_one() {
1378 let config = SpatialHitConfig {
1379 cell_size: 1,
1380 ..Default::default()
1381 };
1382 let idx = SpatialHitIndex::new(10, 5, config);
1383 assert_eq!(idx.grid_width, 10);
1385 assert_eq!(idx.grid_height, 5);
1386 }
1387
1388 #[test]
1389 fn new_with_large_cell_size() {
1390 let config = SpatialHitConfig {
1391 cell_size: 100,
1392 ..Default::default()
1393 };
1394 let idx = SpatialHitIndex::new(80, 24, config);
1395 assert_eq!(idx.grid_width, 1);
1397 assert_eq!(idx.grid_height, 1);
1398 }
1399
1400 #[test]
1401 fn new_zero_dimensions() {
1402 let idx = SpatialHitIndex::with_defaults(0, 0);
1403 assert!(idx.is_empty());
1404 assert!(idx.hit_test_readonly(0, 0).is_none());
1406 }
1407
1408 #[test]
1409 fn with_defaults_uses_default_config() {
1410 let idx = SpatialHitIndex::with_defaults(80, 24);
1411 assert_eq!(idx.config.cell_size, 8);
1412 assert_eq!(idx.config.bucket_warn_threshold, 64);
1413 assert!(!idx.config.track_cache_stats);
1414 }
1415
1416 #[test]
1417 fn index_debug_format() {
1418 let idx = SpatialHitIndex::with_defaults(10, 10);
1419 let dbg = format!("{:?}", idx);
1420 assert!(dbg.contains("SpatialHitIndex"), "Debug: {dbg}");
1421 }
1422
1423 #[test]
1426 fn register_zero_width_rect_not_in_buckets() {
1427 let mut idx = index();
1428 idx.register_simple(HitId::new(1), Rect::new(5, 5, 0, 10), HitRegion::Content, 0);
1429 assert_eq!(idx.len(), 1);
1431 assert!(idx.hit_test(5, 5).is_none());
1432 }
1433
1434 #[test]
1435 fn register_zero_height_rect_not_in_buckets() {
1436 let mut idx = index();
1437 idx.register_simple(HitId::new(1), Rect::new(5, 5, 10, 0), HitRegion::Content, 0);
1438 assert_eq!(idx.len(), 1);
1439 assert!(idx.hit_test(5, 5).is_none());
1440 }
1441
1442 #[test]
1443 fn register_rect_extending_past_screen() {
1444 let mut idx = index();
1445 idx.register_simple(
1447 HitId::new(1),
1448 Rect::new(70, 20, 20, 10),
1449 HitRegion::Content,
1450 0,
1451 );
1452 assert!(idx.hit_test(75, 22).is_some());
1454 assert!(idx.hit_test(85, 25).is_none());
1456 }
1457
1458 #[test]
1459 fn register_many_widgets() {
1460 let mut idx = index();
1461 for i in 0..100u32 {
1462 let x = (i % 8) as u16 * 10;
1463 let y = (i / 8) as u16 * 3;
1464 idx.register_simple(
1465 HitId::new(i + 1),
1466 Rect::new(x, y, 5, 2),
1467 HitRegion::Content,
1468 i as u64,
1469 );
1470 }
1471 assert_eq!(idx.len(), 100);
1472 let result = idx.hit_test(2, 1);
1474 assert!(result.is_some());
1475 }
1476
1477 #[test]
1478 fn register_simple_uses_z_order_zero() {
1479 let mut idx = index();
1480 idx.register_simple(
1481 HitId::new(1),
1482 Rect::new(0, 0, 10, 10),
1483 HitRegion::Content,
1484 0,
1485 );
1486 idx.register(
1488 HitId::new(2),
1489 Rect::new(0, 0, 10, 10),
1490 HitRegion::Border,
1491 0,
1492 1,
1493 );
1494 let result = idx.hit_test(5, 5);
1496 assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 0)));
1497 }
1498
1499 #[test]
1502 fn update_to_zero_size_rect() {
1503 let mut idx = index();
1504 idx.register_simple(
1505 HitId::new(1),
1506 Rect::new(0, 0, 10, 10),
1507 HitRegion::Content,
1508 0,
1509 );
1510 assert!(idx.hit_test(5, 5).is_some());
1511
1512 idx.update(HitId::new(1), Rect::new(0, 0, 0, 0));
1513 assert!(idx.hit_test(0, 0).is_none());
1515 }
1516
1517 #[test]
1518 fn update_shrinks_widget() {
1519 let mut idx = index();
1520 idx.register_simple(
1521 HitId::new(1),
1522 Rect::new(0, 0, 20, 20),
1523 HitRegion::Content,
1524 0,
1525 );
1526 assert!(idx.hit_test(15, 15).is_some());
1527
1528 idx.update(HitId::new(1), Rect::new(0, 0, 5, 5));
1529 assert!(idx.hit_test(15, 15).is_none());
1530 assert!(idx.hit_test(2, 2).is_some());
1531 }
1532
1533 #[test]
1536 fn remove_middle_entry_compacts() {
1537 let mut idx = index();
1538 idx.register_simple(HitId::new(1), Rect::new(0, 0, 5, 5), HitRegion::Content, 10);
1539 idx.register_simple(
1540 HitId::new(2),
1541 Rect::new(10, 0, 5, 5),
1542 HitRegion::Content,
1543 20,
1544 );
1545 idx.register_simple(
1546 HitId::new(3),
1547 Rect::new(20, 0, 5, 5),
1548 HitRegion::Content,
1549 30,
1550 );
1551 assert_eq!(idx.len(), 3);
1552
1553 idx.remove(HitId::new(2));
1554 assert_eq!(idx.len(), 2);
1555
1556 let r1 = idx.hit_test(2, 2);
1558 assert_eq!(r1, Some((HitId::new(1), HitRegion::Content, 10)));
1559 let r3 = idx.hit_test(22, 2);
1560 assert_eq!(r3, Some((HitId::new(3), HitRegion::Content, 30)));
1561 }
1562
1563 #[test]
1564 fn double_remove_returns_false() {
1565 let mut idx = index();
1566 idx.register_simple(
1567 HitId::new(1),
1568 Rect::new(0, 0, 10, 10),
1569 HitRegion::Content,
1570 0,
1571 );
1572 assert!(idx.remove(HitId::new(1)));
1573 assert!(!idx.remove(HitId::new(1)));
1574 }
1575
1576 #[test]
1579 fn hit_test_at_exact_screen_boundary() {
1580 let mut idx = index(); idx.register_simple(
1582 HitId::new(1),
1583 Rect::new(70, 20, 10, 4),
1584 HitRegion::Content,
1585 0,
1586 );
1587 assert!(idx.hit_test(79, 23).is_some());
1589 assert!(idx.hit_test(80, 23).is_none());
1591 assert!(idx.hit_test(79, 24).is_none());
1592 }
1593
1594 #[test]
1595 fn hit_test_at_grid_cell_boundaries() {
1596 let mut idx = index(); idx.register_simple(
1598 HitId::new(1),
1599 Rect::new(6, 6, 4, 4), HitRegion::Content,
1601 0,
1602 );
1603 assert!(idx.hit_test(7, 7).is_some());
1605 assert!(idx.hit_test(8, 8).is_some());
1607 assert!(idx.hit_test(9, 9).is_some());
1609 assert!(idx.hit_test(10, 10).is_none());
1611 }
1612
1613 #[test]
1614 fn hit_test_readonly_out_of_bounds() {
1615 let idx = index();
1616 assert!(idx.hit_test_readonly(80, 0).is_none());
1617 assert!(idx.hit_test_readonly(0, 24).is_none());
1618 assert!(idx.hit_test_readonly(u16::MAX, u16::MAX).is_none());
1619 }
1620
1621 #[test]
1622 fn hit_test_readonly_skips_removed() {
1623 let mut idx = index();
1624 idx.register_simple(
1625 HitId::new(1),
1626 Rect::new(0, 0, 10, 10),
1627 HitRegion::Content,
1628 0,
1629 );
1630 idx.register_simple(HitId::new(2), Rect::new(0, 0, 10, 10), HitRegion::Border, 1);
1631 idx.remove(HitId::new(2));
1632 let result = idx.hit_test_readonly(5, 5);
1634 assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 0)));
1635 }
1636
1637 #[test]
1640 fn cache_updates_on_different_positions() {
1641 let mut idx = SpatialHitIndex::new(
1642 80,
1643 24,
1644 SpatialHitConfig {
1645 track_cache_stats: true,
1646 ..Default::default()
1647 },
1648 );
1649 idx.register_simple(
1650 HitId::new(1),
1651 Rect::new(0, 0, 40, 12),
1652 HitRegion::Content,
1653 1,
1654 );
1655 idx.register_simple(
1656 HitId::new(2),
1657 Rect::new(40, 12, 40, 12),
1658 HitRegion::Border,
1659 2,
1660 );
1661
1662 let r1 = idx.hit_test(5, 5);
1664 assert_eq!(r1, Some((HitId::new(1), HitRegion::Content, 1)));
1665 assert_eq!(idx.stats().misses, 1);
1666
1667 let r2 = idx.hit_test(50, 15);
1669 assert_eq!(r2, Some((HitId::new(2), HitRegion::Border, 2)));
1670 assert_eq!(idx.stats().misses, 2);
1671
1672 let _ = idx.hit_test(5, 5);
1674 assert_eq!(idx.stats().misses, 3);
1675 }
1676
1677 #[test]
1678 fn cache_invalidated_by_invalidate_all_then_same_position() {
1679 let mut idx = SpatialHitIndex::new(
1680 80,
1681 24,
1682 SpatialHitConfig {
1683 track_cache_stats: true,
1684 ..Default::default()
1685 },
1686 );
1687 idx.register_simple(
1688 HitId::new(1),
1689 Rect::new(0, 0, 10, 10),
1690 HitRegion::Content,
1691 0,
1692 );
1693
1694 let _ = idx.hit_test(5, 5);
1696 assert_eq!(idx.stats().misses, 1);
1697 assert_eq!(idx.stats().hits, 0);
1698
1699 idx.invalidate_all();
1701 let _ = idx.hit_test(5, 5);
1702 assert_eq!(idx.stats().misses, 2);
1704 }
1705
1706 #[test]
1707 fn cache_not_updated_by_readonly() {
1708 let mut idx = SpatialHitIndex::new(
1709 80,
1710 24,
1711 SpatialHitConfig {
1712 track_cache_stats: true,
1713 ..Default::default()
1714 },
1715 );
1716 idx.register_simple(
1717 HitId::new(1),
1718 Rect::new(0, 0, 10, 10),
1719 HitRegion::Content,
1720 0,
1721 );
1722
1723 let _ = idx.hit_test_readonly(5, 5);
1725 assert_eq!(idx.stats().hits, 0);
1726 assert_eq!(idx.stats().misses, 0);
1727
1728 let _ = idx.hit_test(5, 5);
1730 assert_eq!(idx.stats().misses, 1);
1731 }
1732
1733 #[test]
1736 fn invalidate_region_zero_size() {
1737 let mut idx = index();
1738 idx.register_simple(
1739 HitId::new(1),
1740 Rect::new(0, 0, 10, 10),
1741 HitRegion::Content,
1742 0,
1743 );
1744 let _ = idx.hit_test(5, 5);
1745 assert!(idx.cache.valid);
1746
1747 idx.invalidate_region(Rect::new(5, 5, 0, 0));
1749 assert!(idx.cache.valid);
1750 }
1751
1752 #[test]
1753 fn invalidate_region_outside_screen() {
1754 let mut idx = index();
1755 idx.register_simple(
1756 HitId::new(1),
1757 Rect::new(0, 0, 10, 10),
1758 HitRegion::Content,
1759 0,
1760 );
1761 let _ = idx.hit_test(5, 5);
1762 assert!(idx.cache.valid);
1763
1764 idx.invalidate_region(Rect::new(100, 100, 10, 10));
1766 assert!(idx.cache.valid);
1768 }
1769
1770 #[test]
1773 fn rebuild_counted_in_stats() {
1774 let mut idx = SpatialHitIndex::new(
1775 80,
1776 24,
1777 SpatialHitConfig {
1778 track_cache_stats: true,
1779 ..Default::default()
1780 },
1781 );
1782 idx.register_simple(
1783 HitId::new(1),
1784 Rect::new(0, 0, 10, 10),
1785 HitRegion::Content,
1786 0,
1787 );
1788 assert_eq!(idx.stats().rebuilds, 0);
1789
1790 idx.update(HitId::new(1), Rect::new(10, 10, 5, 5));
1792 assert_eq!(idx.stats().rebuilds, 1);
1793
1794 idx.remove(HitId::new(1));
1796 assert_eq!(idx.stats().rebuilds, 2);
1797 }
1798
1799 #[test]
1802 fn register_hit_update_hit_remove_clear() {
1803 let mut idx = index();
1804
1805 idx.register_simple(
1807 HitId::new(1),
1808 Rect::new(0, 0, 10, 10),
1809 HitRegion::Content,
1810 0,
1811 );
1812 assert_eq!(idx.len(), 1);
1813
1814 assert!(idx.hit_test(5, 5).is_some());
1816
1817 idx.update(HitId::new(1), Rect::new(20, 20, 10, 10));
1819 assert!(idx.hit_test(5, 5).is_none());
1820 assert!(idx.hit_test(25, 22).is_some());
1821
1822 idx.remove(HitId::new(1));
1824 assert!(idx.is_empty());
1825 assert!(idx.hit_test(25, 22).is_none());
1826
1827 idx.register_simple(HitId::new(2), Rect::new(0, 0, 5, 5), HitRegion::Button, 99);
1829 assert_eq!(idx.len(), 1);
1830 let r = idx.hit_test(2, 2);
1831 assert_eq!(r, Some((HitId::new(2), HitRegion::Button, 99)));
1832
1833 idx.clear();
1835 assert!(idx.is_empty());
1836 assert!(idx.hit_test(2, 2).is_none());
1837 }
1838
1839 #[test]
1842 fn z_order_tie_broken_by_registration_order() {
1843 let mut idx = index();
1844 idx.register(
1846 HitId::new(1),
1847 Rect::new(0, 0, 10, 10),
1848 HitRegion::Content,
1849 10,
1850 5,
1851 );
1852 idx.register(
1853 HitId::new(2),
1854 Rect::new(0, 0, 10, 10),
1855 HitRegion::Border,
1856 20,
1857 5,
1858 );
1859 idx.register(
1860 HitId::new(3),
1861 Rect::new(0, 0, 10, 10),
1862 HitRegion::Button,
1863 30,
1864 5,
1865 );
1866
1867 let result = idx.hit_test(5, 5);
1869 assert_eq!(result, Some((HitId::new(3), HitRegion::Button, 30)));
1870 }
1871
1872 #[test]
1873 fn z_order_higher_z_beats_later_registration() {
1874 let mut idx = index();
1875 idx.register(
1877 HitId::new(1),
1878 Rect::new(0, 0, 10, 10),
1879 HitRegion::Content,
1880 10,
1881 10,
1882 );
1883 idx.register(
1885 HitId::new(2),
1886 Rect::new(0, 0, 10, 10),
1887 HitRegion::Border,
1888 20,
1889 5,
1890 );
1891
1892 let result = idx.hit_test(5, 5);
1894 assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 10)));
1895 }
1896
1897 #[test]
1900 fn all_hit_region_variants_returned() {
1901 let mut idx = index();
1902 let regions = [
1903 (1, HitRegion::Content),
1904 (2, HitRegion::Border),
1905 (3, HitRegion::Scrollbar),
1906 (4, HitRegion::Handle),
1907 (5, HitRegion::Button),
1908 (6, HitRegion::Link),
1909 (7, HitRegion::Custom(42)),
1910 ];
1911 for (i, (id, region)) in regions.iter().enumerate() {
1912 let x = (i as u16) * 10;
1913 idx.register_simple(HitId::new(*id), Rect::new(x, 0, 5, 5), *region, *id as u64);
1914 }
1915 for (i, (id, region)) in regions.iter().enumerate() {
1916 let x = (i as u16) * 10 + 2;
1917 let result = idx.hit_test(x, 2);
1918 assert_eq!(
1919 result,
1920 Some((HitId::new(*id), *region, *id as u64)),
1921 "Failed for region {:?}",
1922 region
1923 );
1924 }
1925 }
1926
1927 #[test]
1930 fn single_cell_screen() {
1931 let mut idx = SpatialHitIndex::with_defaults(1, 1);
1932 idx.register_simple(HitId::new(1), Rect::new(0, 0, 1, 1), HitRegion::Content, 0);
1933 assert!(idx.hit_test(0, 0).is_some());
1934 assert!(idx.hit_test(1, 0).is_none());
1935 }
1936
1937 #[test]
1940 fn hit_test_readonly_equivalent_to_mutable_for_grid() {
1941 let mut idx = index();
1942 idx.register(
1943 HitId::new(1),
1944 Rect::new(0, 0, 40, 12),
1945 HitRegion::Content,
1946 1,
1947 0,
1948 );
1949 idx.register(
1950 HitId::new(2),
1951 Rect::new(30, 8, 20, 10),
1952 HitRegion::Border,
1953 2,
1954 1,
1955 );
1956 idx.register(
1957 HitId::new(3),
1958 Rect::new(60, 0, 20, 24),
1959 HitRegion::Button,
1960 3,
1961 2,
1962 );
1963
1964 for x in (0..80).step_by(5) {
1966 for y in (0..24).step_by(3) {
1967 let ro = idx.hit_test_readonly(x, y);
1968 let expected_id = ro.map(|(id, _, _)| id);
1969 let ro2 = idx.hit_test_readonly(x, y);
1972 assert_eq!(ro, ro2, "Readonly inconsistency at ({x}, {y})");
1973 let mut_result = idx.hit_test(x, y);
1975 let mut_id = mut_result.map(|(id, _, _)| id);
1976 assert_eq!(
1977 expected_id, mut_id,
1978 "Mutable/readonly mismatch at ({x}, {y})"
1979 );
1980 }
1981 }
1982 }
1983}