1#![forbid(unsafe_code)]
2
3use crate::frame::{HitData, HitId, HitRegion};
33use ahash::AHashMap;
34use ftui_core::geometry::Rect;
35
36#[derive(Debug, Clone)]
42pub struct SpatialHitConfig {
43 pub cell_size: u16,
46
47 pub bucket_warn_threshold: usize,
49
50 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct HitEntry {
71 pub id: HitId,
73 pub rect: Rect,
75 pub region: HitRegion,
77 pub data: HitData,
79 pub z_order: u16,
81 order: u32,
83}
84
85impl HitEntry {
86 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 #[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 #[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#[derive(Debug, Clone, Default)]
130struct Bucket {
131 entries: Vec<u32>,
133}
134
135impl Bucket {
136 #[inline]
138 fn push(&mut self, entry_idx: u32) {
139 self.entries.push(entry_idx);
140 }
141
142 #[inline]
144 fn clear(&mut self) {
145 self.entries.clear();
146 }
147}
148
149#[derive(Debug, Clone, Copy, Default)]
155struct HoverCache {
156 pos: (u16, u16),
158 result: Option<u32>,
160 valid: bool,
162}
163
164#[derive(Debug, Clone, Default)]
170struct DirtyTracker {
171 dirty_rects: Vec<Rect>,
173 full_rebuild: bool,
175}
176
177impl DirtyTracker {
178 fn mark_dirty(&mut self, rect: Rect) {
180 if !self.full_rebuild {
181 self.dirty_rects.push(rect);
182 }
183 }
184
185 fn mark_full_rebuild(&mut self) {
187 self.full_rebuild = true;
188 self.dirty_rects.clear();
189 }
190
191 fn clear(&mut self) {
193 self.dirty_rects.clear();
194 self.full_rebuild = false;
195 }
196
197 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#[derive(Debug, Clone, Copy, Default)]
221pub struct CacheStats {
222 pub hits: u64,
224 pub misses: u64,
226 pub rebuilds: u64,
228}
229
230impl CacheStats {
231 #[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#[derive(Debug)]
252pub struct SpatialHitIndex {
253 config: SpatialHitConfig,
254
255 width: u16,
257 height: u16,
258
259 grid_width: u16,
261 grid_height: u16,
262
263 entries: Vec<HitEntry>,
265
266 buckets: Vec<Bucket>,
268
269 next_order: u32,
271
272 cache: HoverCache,
274
275 dirty: DirtyTracker,
277
278 stats: CacheStats,
280
281 id_to_entry: AHashMap<HitId, u32>,
283}
284
285impl SpatialHitIndex {
286 pub fn new(width: u16, height: u16, config: SpatialHitConfig) -> Self {
288 let cell_size = config.cell_size.max(1);
289 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 pub fn with_defaults(width: u16, height: u16) -> Self {
314 Self::new(width, height, SpatialHitConfig::default())
315 }
316
317 pub fn register(
327 &mut self,
328 id: HitId,
329 rect: Rect,
330 region: HitRegion,
331 data: HitData,
332 z_order: u16,
333 ) {
334 if id == HitId::default() {
340 return;
341 }
342
343 if self.id_to_entry.contains_key(&id) {
348 self.remove(id);
349 }
350
351 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 self.add_to_buckets(entry_idx, rect);
361
362 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 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 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 self.dirty.mark_dirty(old_rect);
386 self.dirty.mark_dirty(new_rect);
387
388 self.entries[entry_idx as usize].rect = new_rect;
390
391 self.rebuild_buckets();
394
395 self.cache.valid = false;
397
398 true
399 }
400
401 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 self.entries[entry_idx as usize].id = HitId::default();
414 self.id_to_entry.remove(&id);
415
416 self.rebuild_buckets();
418 self.cache.valid = false;
419
420 true
421 }
422
423 #[must_use]
432 pub fn hit_test(&mut self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
433 if x >= self.width || y >= self.height {
435 return None;
436 }
437
438 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 let bucket_idx = self.bucket_index(x, y);
455 let bucket = &self.buckets[bucket_idx];
456
457 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 if entry.id == HitId::default() {
466 continue;
467 }
468
469 if entry.contains(x, y) {
471 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 self.cache.pos = (x, y);
488 self.cache.result = best_idx;
489 self.cache.valid = true;
490 self.dirty.clear();
492
493 best.map(|e| (e.id, e.region, e.data))
494 }
495
496 #[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 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 #[must_use]
541 pub fn stats(&self) -> CacheStats {
542 self.stats
543 }
544
545 pub fn reset_stats(&mut self) {
547 self.stats = CacheStats::default();
548 }
549
550 #[inline]
552 #[must_use]
553 pub fn len(&self) -> usize {
554 self.id_to_entry.len()
555 }
556
557 #[inline]
559 #[must_use]
560 pub fn is_empty(&self) -> bool {
561 self.id_to_entry.is_empty()
562 }
563
564 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 pub fn invalidate_all(&mut self) {
574 self.cache.valid = false;
575 self.dirty.mark_full_rebuild();
576 }
577
578 #[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 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 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 if self.buckets[bucket_idx].entries.len() > self.config.bucket_warn_threshold {
625 }
627 }
628 }
629 }
630 }
631
632 fn rebuild_buckets(&mut self) {
634 for bucket in &mut self.buckets {
636 bucket.clear();
637 }
638
639 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 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 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 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#[cfg(test)]
692mod tests {
693 use super::*;
694
695 fn index() -> SpatialHitIndex {
696 SpatialHitIndex::with_defaults(80, 24)
697 }
698
699 #[test]
702 fn u16_max_dimensions_do_not_panic_or_miss_hits() {
703 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 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 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 #[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 let result = idx.hit_test(15, 6);
789 assert_eq!(result, Some((HitId::new(1), HitRegion::Button, 42)));
790
791 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 idx.register(
802 HitId::new(1),
803 Rect::new(0, 0, 10, 10),
804 HitRegion::Content,
805 1,
806 0, );
808 idx.register(
809 HitId::new(2),
810 Rect::new(5, 5, 10, 10),
811 HitRegion::Border,
812 2,
813 1, );
815
816 let result = idx.hit_test(7, 7);
818 assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 2)));
819
820 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 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 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 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()); }
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 assert!(idx.hit_test(5, 5).is_some());
885
886 let updated = idx.update(HitId::new(1), Rect::new(50, 10, 10, 10));
888 assert!(updated);
889
890 assert!(idx.hit_test(5, 5).is_none());
892
893 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 #[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 let _ = idx.hit_test(5, 5);
962 assert_eq!(idx.stats().misses, 1);
963 assert_eq!(idx.stats().hits, 0);
964
965 let _ = idx.hit_test(5, 5);
967 assert_eq!(idx.stats().hits, 1);
968
969 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 let _ = idx.hit_test(5, 5);
993
994 idx.register_simple(HitId::new(2), Rect::new(0, 0, 10, 10), HitRegion::Button, 1);
996
997 let hits_before = idx.stats().hits;
999 let _ = idx.hit_test(5, 5);
1000 assert_eq!(idx.stats().hits, hits_before);
1002 }
1003
1004 #[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 for x in 0..60 {
1021 for y in 0..20 {
1022 let indexed_result = idx.hit_test_readonly(x, y);
1023
1024 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 #[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 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 idx.register_simple(
1088 HitId::new(1),
1089 Rect::new(0, 0, 80, 24),
1090 HitRegion::Content,
1091 0,
1092 );
1093
1094 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 let _ = idx.hit_test(5, 5);
1144 assert!(idx.cache.valid);
1145
1146 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 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 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 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); 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 #[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 #[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; assert_eq!(entry, copied);
1338 let cloned: HitEntry = entry; 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 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 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 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 #[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; 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 #[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 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 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 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 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 #[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 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 idx.register_simple(
1538 HitId::new(1),
1539 Rect::new(70, 20, 20, 10),
1540 HitRegion::Content,
1541 0,
1542 );
1543 assert!(idx.hit_test(75, 22).is_some());
1545 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 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 idx.register(
1579 HitId::new(2),
1580 Rect::new(0, 0, 10, 10),
1581 HitRegion::Border,
1582 0,
1583 1,
1584 );
1585 let result = idx.hit_test(5, 5);
1587 assert_eq!(result, Some((HitId::new(2), HitRegion::Border, 0)));
1588 }
1589
1590 #[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 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 #[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 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 #[test]
1670 fn hit_test_at_exact_screen_boundary() {
1671 let mut idx = index(); idx.register_simple(
1673 HitId::new(1),
1674 Rect::new(70, 20, 10, 4),
1675 HitRegion::Content,
1676 0,
1677 );
1678 assert!(idx.hit_test(79, 23).is_some());
1680 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(); idx.register_simple(
1689 HitId::new(1),
1690 Rect::new(6, 6, 4, 4), HitRegion::Content,
1692 0,
1693 );
1694 assert!(idx.hit_test(7, 7).is_some());
1696 assert!(idx.hit_test(8, 8).is_some());
1698 assert!(idx.hit_test(9, 9).is_some());
1700 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 let result = idx.hit_test_readonly(5, 5);
1725 assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 0)));
1726 }
1727
1728 #[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 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 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 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 let _ = idx.hit_test(5, 5);
1787 assert_eq!(idx.stats().misses, 1);
1788 assert_eq!(idx.stats().hits, 0);
1789
1790 idx.invalidate_all();
1792 let _ = idx.hit_test(5, 5);
1793 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 let _ = idx.hit_test_readonly(5, 5);
1816 assert_eq!(idx.stats().hits, 0);
1817 assert_eq!(idx.stats().misses, 0);
1818
1819 let _ = idx.hit_test(5, 5);
1821 assert_eq!(idx.stats().misses, 1);
1822 }
1823
1824 #[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 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 idx.invalidate_region(Rect::new(100, 100, 10, 10));
1857 assert!(idx.cache.valid);
1859 }
1860
1861 #[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 idx.update(HitId::new(1), Rect::new(10, 10, 5, 5));
1883 assert_eq!(idx.stats().rebuilds, 1);
1884
1885 idx.remove(HitId::new(1));
1887 assert_eq!(idx.stats().rebuilds, 2);
1888 }
1889
1890 #[test]
1893 fn register_hit_update_hit_remove_clear() {
1894 let mut idx = index();
1895
1896 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 assert!(idx.hit_test(5, 5).is_some());
1907
1908 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 idx.remove(HitId::new(1));
1915 assert!(idx.is_empty());
1916 assert!(idx.hit_test(25, 22).is_none());
1917
1918 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 idx.clear();
1926 assert!(idx.is_empty());
1927 assert!(idx.hit_test(2, 2).is_none());
1928 }
1929
1930 #[test]
1933 fn z_order_tie_broken_by_registration_order() {
1934 let mut idx = index();
1935 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 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 idx.register(
1968 HitId::new(1),
1969 Rect::new(0, 0, 10, 10),
1970 HitRegion::Content,
1971 10,
1972 10,
1973 );
1974 idx.register(
1976 HitId::new(2),
1977 Rect::new(0, 0, 10, 10),
1978 HitRegion::Border,
1979 20,
1980 5,
1981 );
1982
1983 let result = idx.hit_test(5, 5);
1985 assert_eq!(result, Some((HitId::new(1), HitRegion::Content, 10)));
1986 }
1987
1988 #[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 #[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 #[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 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 let ro2 = idx.hit_test_readonly(x, y);
2063 assert_eq!(ro, ro2, "Readonly inconsistency at ({x}, {y})");
2064 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}