1use crate::tile_source::{TileData, TileError, TileFreshness, TileResponse};
75use rustial_math::TileId;
76use std::collections::HashMap;
77use std::time::SystemTime;
78
79const PENDING_ENTRY_CAP_DIVISOR: usize = 4;
86
87const PENDING_ENTRY_CAP_MIN_CACHE_SIZE: usize = 64;
93
94#[derive(Debug)]
104struct LruLink {
105 prev: Option<TileId>,
106 next: Option<TileId>,
107}
108
109#[derive(Debug)]
115pub struct EvictedTile {
116 pub id: TileId,
118 pub entry: TileCacheEntry,
120}
121
122#[derive(Debug, Clone, Default, PartialEq, Eq)]
124pub struct TileCacheStats {
125 pub total_entries: usize,
127 pub pending_entries: usize,
129 pub loaded_entries: usize,
131 pub expired_entries: usize,
133 pub reloading_entries: usize,
135 pub failed_entries: usize,
137 pub renderable_entries: usize,
139}
140
141impl EvictedTile {
142 #[inline]
144 pub fn was_pending(&self) -> bool {
145 self.entry.is_pending()
146 }
147}
148
149#[derive(Debug, Default)]
151pub struct InsertPendingResult {
152 pub inserted: bool,
154 pub evicted: Vec<EvictedTile>,
156}
157
158#[derive(Debug, Clone)]
160pub struct CachedTile {
161 pub data: TileData,
163 pub freshness: TileFreshness,
165 pub loaded_at: SystemTime,
171}
172
173impl CachedTile {
174 #[inline]
176 pub fn is_expired_at(&self, now: SystemTime) -> bool {
177 self.freshness.is_expired_at(now)
178 }
179
180 #[inline]
182 pub fn is_expired(&self) -> bool {
183 self.freshness.is_expired()
184 }
185}
186
187#[derive(Debug)]
189pub enum TileCacheEntry {
190 Pending,
192 Loaded(CachedTile),
194 Expired(CachedTile),
196 Reloading(CachedTile),
198 Failed {
201 error: String,
203 stale: Option<CachedTile>,
205 },
206}
207
208impl TileCacheEntry {
209 #[inline]
211 pub fn is_pending(&self) -> bool {
212 matches!(self, Self::Pending)
213 }
214
215 #[inline]
217 pub fn is_loaded(&self) -> bool {
218 matches!(self, Self::Loaded(_) | Self::Expired(_) | Self::Reloading(_))
219 }
220
221 #[inline]
223 pub fn is_expired(&self) -> bool {
224 matches!(self, Self::Expired(_))
225 }
226
227 #[inline]
229 pub fn is_reloading(&self) -> bool {
230 matches!(self, Self::Reloading(_))
231 }
232
233 #[inline]
235 pub fn is_failed(&self) -> bool {
236 matches!(self, Self::Failed { .. })
237 }
238
239 #[inline]
241 pub fn is_renderable(&self) -> bool {
242 self.data().is_some()
243 }
244
245 #[inline]
247 pub fn data(&self) -> Option<&TileData> {
248 self.cached_tile().map(|tile| &tile.data)
249 }
250
251 #[inline]
253 pub fn cached_tile(&self) -> Option<&CachedTile> {
254 match self {
255 Self::Loaded(tile) | Self::Expired(tile) | Self::Reloading(tile) => Some(tile),
256 Self::Failed { stale, .. } => stale.as_ref(),
257 Self::Pending => None,
258 }
259 }
260
261 #[inline]
263 pub fn freshness(&self) -> Option<&TileFreshness> {
264 self.cached_tile().map(|tile| &tile.freshness)
265 }
266
267 #[inline]
271 pub fn loaded_at(&self) -> Option<SystemTime> {
272 self.cached_tile().map(|tile| tile.loaded_at)
273 }
274}
275
276pub struct TileCache {
285 entries: HashMap<TileId, (TileCacheEntry, LruLink)>,
287 lru_head: Option<TileId>,
289 lru_tail: Option<TileId>,
291 max_entries: usize,
293 max_bytes: Option<usize>,
296 total_bytes: usize,
298}
299
300impl TileCache {
301 pub fn new(max_entries: usize) -> Self {
307 Self {
308 entries: HashMap::with_capacity(max_entries),
309 lru_head: None,
310 lru_tail: None,
311 max_entries,
312 max_bytes: None,
313 total_bytes: 0,
314 }
315 }
316
317 pub fn with_byte_budget(max_entries: usize, max_bytes: usize) -> Self {
321 Self {
322 entries: HashMap::with_capacity(max_entries),
323 lru_head: None,
324 lru_tail: None,
325 max_entries,
326 max_bytes: Some(max_bytes),
327 total_bytes: 0,
328 }
329 }
330
331 #[inline]
338 pub fn get(&self, id: &TileId) -> Option<&TileCacheEntry> {
339 self.entries.get(id).map(|(entry, _)| entry)
340 }
341
342 #[inline]
344 pub fn contains(&self, id: &TileId) -> bool {
345 self.entries.contains_key(id)
346 }
347
348 #[inline]
350 pub fn len(&self) -> usize {
351 self.entries.len()
352 }
353
354 #[inline]
356 pub fn is_empty(&self) -> bool {
357 self.entries.is_empty()
358 }
359
360 #[inline]
362 pub fn capacity(&self) -> usize {
363 self.max_entries
364 }
365
366 #[inline]
368 fn max_pending_entries(&self) -> usize {
369 if self.max_entries == 0 {
370 0
371 } else if self.max_entries < PENDING_ENTRY_CAP_MIN_CACHE_SIZE {
372 usize::MAX
373 } else {
374 (self.max_entries / PENDING_ENTRY_CAP_DIVISOR).max(1)
375 }
376 }
377
378 #[inline]
380 fn pending_entry_count(&self) -> usize {
381 self.entries
382 .values()
383 .filter(|(entry, _)| entry.is_pending())
384 .count()
385 }
386
387 #[inline]
389 pub fn total_bytes(&self) -> usize {
390 self.total_bytes
391 }
392
393 #[inline]
395 pub fn max_bytes(&self) -> Option<usize> {
396 self.max_bytes
397 }
398
399 pub fn pending_ids(&self) -> Vec<TileId> {
401 self.entries
402 .iter()
403 .filter_map(|(id, (entry, _))| entry.is_pending().then_some(*id))
404 .collect()
405 }
406
407 pub fn inflight_ids(&self) -> Vec<TileId> {
409 self.entries
410 .iter()
411 .filter_map(|(id, (entry, _))| match entry {
412 TileCacheEntry::Pending | TileCacheEntry::Reloading(_) => Some(*id),
413 _ => None,
414 })
415 .collect()
416 }
417
418 pub fn expired_ids_at(&self, now: SystemTime) -> Vec<TileId> {
420 self.entries
421 .iter()
422 .filter_map(|(id, (entry, _))| match entry {
423 TileCacheEntry::Loaded(tile) if tile.is_expired_at(now) => Some(*id),
424 TileCacheEntry::Expired(_) => Some(*id),
425 _ => None,
426 })
427 .collect()
428 }
429
430 pub fn expired_ids(&self) -> Vec<TileId> {
432 self.expired_ids_at(SystemTime::now())
433 }
434
435 pub fn stats(&self) -> TileCacheStats {
437 let mut stats = TileCacheStats {
438 total_entries: self.entries.len(),
439 ..TileCacheStats::default()
440 };
441
442 for (entry, _) in self.entries.values() {
443 match entry {
444 TileCacheEntry::Pending => stats.pending_entries += 1,
445 TileCacheEntry::Loaded(_) => stats.loaded_entries += 1,
446 TileCacheEntry::Expired(_) => stats.expired_entries += 1,
447 TileCacheEntry::Reloading(_) => stats.reloading_entries += 1,
448 TileCacheEntry::Failed { .. } => stats.failed_entries += 1,
449 }
450 if entry.is_renderable() {
451 stats.renderable_entries += 1;
452 }
453 }
454
455 stats
456 }
457
458 fn lru_unlink(&mut self, id: &TileId) {
463 let Some((_, link)) = self.entries.get(id) else { return };
464 let prev = link.prev;
465 let next = link.next;
466
467 if let Some(p) = prev {
468 if let Some((_, plink)) = self.entries.get_mut(&p) {
469 plink.next = next;
470 }
471 } else {
472 self.lru_head = next;
473 }
474
475 if let Some(n) = next {
476 if let Some((_, nlink)) = self.entries.get_mut(&n) {
477 nlink.prev = prev;
478 }
479 } else {
480 self.lru_tail = prev;
481 }
482
483 if let Some((_, link)) = self.entries.get_mut(id) {
485 link.prev = None;
486 link.next = None;
487 }
488 }
489
490 fn lru_push_back(&mut self, id: &TileId) {
492 if let Some(old_tail) = self.lru_tail {
493 if let Some((_, tlink)) = self.entries.get_mut(&old_tail) {
494 tlink.next = Some(*id);
495 }
496 if let Some((_, link)) = self.entries.get_mut(id) {
497 link.prev = Some(old_tail);
498 link.next = None;
499 }
500 self.lru_tail = Some(*id);
501 } else {
502 if let Some((_, link)) = self.entries.get_mut(id) {
504 link.prev = None;
505 link.next = None;
506 }
507 self.lru_head = Some(*id);
508 self.lru_tail = Some(*id);
509 }
510 }
511
512 pub fn touch(&mut self, id: &TileId) -> bool {
518 if !self.entries.contains_key(id) {
519 return false;
520 }
521 self.lru_unlink(id);
522 self.lru_push_back(id);
523 true
524 }
525
526 pub fn insert_pending_with_eviction(&mut self, id: TileId) -> InsertPendingResult {
529 if self.max_entries == 0 || self.entries.contains_key(&id) {
530 return InsertPendingResult::default();
531 }
532
533 if self.pending_entry_count() >= self.max_pending_entries() {
534 return InsertPendingResult::default();
535 }
536
537 let evicted = self.evict_if_full_for_pending_insert();
538 if self.entries.len() >= self.max_entries {
539 return InsertPendingResult::default();
540 }
541 self.entries.insert(id, (TileCacheEntry::Pending, LruLink { prev: None, next: None }));
542 self.lru_push_back(&id);
543
544 InsertPendingResult {
545 inserted: true,
546 evicted,
547 }
548 }
549
550 pub fn insert_pending(&mut self, id: TileId) -> bool {
554 self.insert_pending_with_eviction(id).inserted
555 }
556
557 pub fn promote_with_eviction(&mut self, id: TileId, response: TileResponse) -> Vec<EvictedTile> {
560 if self.max_entries == 0 {
561 return Vec::new();
562 }
563
564 let byte_len = response.data.byte_len();
565
566 let evicted = if !self.entries.contains_key(&id) {
567 let evicted = self.evict_if_full();
568 self.entries.insert(id, (TileCacheEntry::Pending, LruLink { prev: None, next: None }));
569 self.lru_push_back(&id);
570 evicted
571 } else {
572 if let Some((old_entry, _)) = self.entries.get(&id) {
574 if let Some(data) = old_entry.data() {
575 self.total_bytes = self.total_bytes.saturating_sub(data.byte_len());
576 }
577 }
578 self.touch(&id);
579 Vec::new()
580 };
581
582 self.entries.get_mut(&id).unwrap().0 = TileCacheEntry::Loaded(CachedTile {
583 data: response.data,
584 freshness: response.freshness,
585 loaded_at: SystemTime::now(),
586 });
587 self.total_bytes += byte_len;
588
589 let mut extra_evicted = Vec::new();
592 if let Some(max) = self.max_bytes {
593 while self.total_bytes > max && self.entries.len() > 1 {
594 if let Some(old) = self.lru_head {
595 if old == id {
596 break; }
598 if let Some(ev) = self.remove_and_return(old) {
599 extra_evicted.push(ev);
600 }
601 } else {
602 break;
603 }
604 }
605 }
606
607 if extra_evicted.is_empty() {
608 evicted
609 } else {
610 let mut all = evicted;
611 all.extend(extra_evicted);
612 all
613 }
614 }
615
616 pub fn promote(&mut self, id: TileId, response: TileResponse) {
619 let _ = self.promote_with_eviction(id, response);
620 }
621
622 pub fn mark_expired(&mut self, id: TileId) -> bool {
624 let Some((entry, _)) = self.entries.get_mut(&id) else {
625 return false;
626 };
627
628 match entry {
629 TileCacheEntry::Loaded(tile) => {
630 let tile = tile.clone();
631 *entry = TileCacheEntry::Expired(tile);
632 true
633 }
634 TileCacheEntry::Expired(_) | TileCacheEntry::Reloading(_) => true,
635 TileCacheEntry::Pending | TileCacheEntry::Failed { .. } => false,
636 }
637 }
638
639 pub fn start_reload(&mut self, id: TileId) -> bool {
641 let transitioned = match self.entries.get_mut(&id) {
642 Some((TileCacheEntry::Loaded(tile), _)) | Some((TileCacheEntry::Expired(tile), _)) => {
643 let tile = tile.clone();
644 self.entries.get_mut(&id).expect("entry should exist").0 = TileCacheEntry::Reloading(tile);
645 true
646 }
647 _ => false,
648 };
649
650 if transitioned {
651 self.touch(&id);
652 }
653
654 transitioned
655 }
656
657 pub fn refresh_ttl(&mut self, id: TileId, freshness: TileFreshness) -> bool {
662 let Some((entry, _)) = self.entries.get_mut(&id) else {
663 return false;
664 };
665
666 match entry {
667 TileCacheEntry::Reloading(tile) | TileCacheEntry::Expired(tile) => {
668 tile.freshness = freshness;
669 let tile = tile.clone();
670 *entry = TileCacheEntry::Loaded(tile);
671 self.touch(&id);
672 true
673 }
674 TileCacheEntry::Loaded(tile) => {
675 tile.freshness = freshness;
676 true
677 }
678 TileCacheEntry::Pending | TileCacheEntry::Failed { .. } => false,
679 }
680 }
681
682 pub fn revalidation_hint(&self, id: &TileId) -> Option<crate::tile_source::RevalidationHint> {
684 let (entry, _) = self.entries.get(id)?;
685 let freshness = entry.freshness()?;
686 Some(crate::tile_source::RevalidationHint {
687 etag: freshness.etag.clone(),
688 last_modified: freshness.last_modified.clone(),
689 })
690 }
691
692 pub fn mark_failed(&mut self, id: TileId, error: &TileError) {
694 if let Some((entry, _)) = self.entries.get_mut(&id) {
695 match entry {
696 TileCacheEntry::Reloading(tile) | TileCacheEntry::Expired(tile) => {
697 let tile = tile.clone();
698 *entry = TileCacheEntry::Expired(tile);
699 }
700 TileCacheEntry::Loaded(_) | TileCacheEntry::Pending | TileCacheEntry::Failed { .. } => {
701 let stale = match entry {
702 TileCacheEntry::Loaded(tile) => Some(tile.clone()),
703 TileCacheEntry::Failed { stale, .. } => stale.clone(),
704 _ => None,
705 };
706 *entry = TileCacheEntry::Failed {
707 error: error.to_string(),
708 stale,
709 };
710 }
711 }
712 }
713 }
714
715 pub fn cancel_reload(&mut self, id: &TileId) -> bool {
721 let Some((entry, _)) = self.entries.get_mut(id) else {
722 return false;
723 };
724 match entry {
725 TileCacheEntry::Reloading(tile) => {
726 let tile = tile.clone();
727 *entry = TileCacheEntry::Expired(tile);
728 true
729 }
730 _ => false,
731 }
732 }
733
734 pub fn remove(&mut self, id: &TileId) -> bool {
738 self.remove_and_return(*id).is_some()
739 }
740
741 pub fn clear(&mut self) {
743 for (_, (entry, _)) in self.entries.drain() {
744 if let Some(data) = entry.data() {
745 self.total_bytes = self.total_bytes.saturating_sub(data.byte_len());
746 }
747 }
748 self.lru_head = None;
749 self.lru_tail = None;
750 self.total_bytes = 0;
751 }
752
753 fn remove_and_return(&mut self, id: TileId) -> Option<EvictedTile> {
757 self.lru_unlink(&id);
758 if let Some((entry, _)) = self.entries.remove(&id) {
759 if let Some(data) = entry.data() {
760 self.total_bytes = self.total_bytes.saturating_sub(data.byte_len());
761 }
762 Some(EvictedTile { id, entry })
763 } else {
764 None
765 }
766 }
767
768 fn evict_if_full(&mut self) -> Vec<EvictedTile> {
776 let mut evicted = Vec::new();
777 while self.entries.len() >= self.max_entries {
778 let victim = self.find_failed_lru()
785 .or_else(|| self.find_non_pending_lru())
786 .or_else(|| self.lru_head);
787 if let Some(id) = victim {
788 if let Some(ev) = self.remove_and_return(id) {
789 evicted.push(ev);
790 }
791 } else {
792 break;
793 }
794 }
795 evicted
796 }
797
798 fn evict_if_full_for_pending_insert(&mut self) -> Vec<EvictedTile> {
809 let mut evicted = Vec::new();
810 while self.entries.len() >= self.max_entries {
811 let victim = self
812 .find_non_renderable_lru()
813 .or_else(|| self.find_pending_lru())
814 .or_else(|| self.find_non_renderable_non_pending_lru())
815 .or_else(|| self.lru_head);
816 if let Some(id) = victim {
817 if let Some(ev) = self.remove_and_return(id) {
818 evicted.push(ev);
819 }
820 } else {
821 break;
822 }
823 }
824 evicted
825 }
826
827 fn find_failed_lru(&self) -> Option<TileId> {
829 let mut cursor = self.lru_head;
830 while let Some(id) = cursor {
831 if let Some((entry, link)) = self.entries.get(&id) {
832 if entry.is_failed() {
833 return Some(id);
834 }
835 cursor = link.next;
836 } else {
837 break;
838 }
839 }
840 None
841 }
842
843 fn find_non_renderable_lru(&self) -> Option<TileId> {
845 let mut cursor = self.lru_head;
846 while let Some(id) = cursor {
847 if let Some((entry, link)) = self.entries.get(&id) {
848 if !entry.is_renderable() {
849 return Some(id);
850 }
851 cursor = link.next;
852 } else {
853 break;
854 }
855 }
856 None
857 }
858
859 fn find_non_renderable_non_pending_lru(&self) -> Option<TileId> {
862 let mut cursor = self.lru_head;
863 while let Some(id) = cursor {
864 if let Some((entry, link)) = self.entries.get(&id) {
865 if !entry.is_pending() && !entry.is_renderable() {
866 return Some(id);
867 }
868 cursor = link.next;
869 } else {
870 break;
871 }
872 }
873 None
874 }
875
876 fn find_pending_lru(&self) -> Option<TileId> {
878 let mut cursor = self.lru_head;
879 while let Some(id) = cursor {
880 if let Some((entry, link)) = self.entries.get(&id) {
881 if entry.is_pending() {
882 return Some(id);
883 }
884 cursor = link.next;
885 } else {
886 break;
887 }
888 }
889 None
890 }
891
892 fn find_non_pending_lru(&self) -> Option<TileId> {
898 let mut cursor = self.lru_head;
899 while let Some(id) = cursor {
900 if let Some((entry, link)) = self.entries.get(&id) {
901 if !entry.is_pending() {
902 return Some(id);
903 }
904 cursor = link.next;
905 } else {
906 break;
907 }
908 }
909 None
910 }
911}
912
913#[cfg(test)]
918mod tests {
919 use super::*;
920 use crate::tile_source::{DecodedImage, TileResponse};
921 use std::time::Duration;
922
923 fn dummy_tile_data() -> TileData {
924 TileData::Raster(DecodedImage {
925 width: 256,
926 height: 256,
927 data: vec![0u8; 256 * 256 * 4].into(),
928 })
929 }
930
931 fn dummy_tile_response() -> TileResponse {
932 TileResponse::from_data(dummy_tile_data())
933 }
934
935 fn expiring_tile_response() -> TileResponse {
936 TileResponse::from_data(dummy_tile_data()).with_freshness(TileFreshness {
937 expires_at: Some(SystemTime::now() - Duration::from_secs(1)),
938 etag: Some("etag-1".into()),
939 last_modified: None,
940 })
941 }
942
943 #[test]
946 fn new_cache_is_empty() {
947 let cache = TileCache::new(10);
948 assert!(cache.is_empty());
949 assert_eq!(cache.len(), 0);
950 assert_eq!(cache.capacity(), 10);
951 }
952
953 #[test]
954 fn zero_capacity_cache() {
955 let mut cache = TileCache::new(0);
956 let result = cache.insert_pending_with_eviction(TileId::new(0, 0, 0));
957 assert!(!result.inserted);
958 assert!(result.evicted.is_empty());
959 assert!(cache.is_empty());
960 }
961
962 #[test]
965 fn insert_and_get() {
966 let mut cache = TileCache::new(10);
967 let id = TileId::new(0, 0, 0);
968 assert!(cache.insert_pending(id));
969 assert!(cache.contains(&id));
970 assert!(cache.get(&id).unwrap().is_pending());
971 }
972
973 #[test]
974 fn no_double_insert() {
975 let mut cache = TileCache::new(10);
976 let id = TileId::new(0, 0, 0);
977 assert!(cache.insert_pending(id));
978 assert!(!cache.insert_pending(id));
979 assert_eq!(cache.len(), 1);
980 }
981
982 #[test]
983 fn insert_does_not_overwrite_loaded() {
984 let mut cache = TileCache::new(10);
985 let id = TileId::new(0, 0, 0);
986 cache.insert_pending(id);
987 cache.promote(id, dummy_tile_response());
988 assert!(!cache.insert_pending(id));
990 assert!(cache.get(&id).unwrap().is_loaded());
991 }
992
993 #[test]
996 fn promote_to_loaded() {
997 let mut cache = TileCache::new(10);
998 let id = TileId::new(0, 0, 0);
999 cache.insert_pending(id);
1000 cache.promote(id, dummy_tile_response());
1001 let entry = cache.get(&id).unwrap();
1002 assert!(entry.is_loaded());
1003 assert!(entry.data().is_some());
1004 }
1005
1006 #[test]
1007 fn promote_without_prior_pending() {
1008 let mut cache = TileCache::new(10);
1011 let id = TileId::new(5, 10, 10);
1012 cache.promote(id, dummy_tile_response());
1013 assert!(cache.contains(&id));
1014 assert!(cache.get(&id).unwrap().is_loaded());
1015 }
1016
1017 #[test]
1018 fn pending_entry_cap_rejects_excess_pending_inserts() {
1019 let mut cache = TileCache::new(64);
1020 let ids: Vec<TileId> = (0..17).map(|i| TileId::new(5, i, 0)).collect();
1021
1022 assert_eq!(cache.max_pending_entries(), 16);
1023 for &id in &ids[..16] {
1024 assert!(cache.insert_pending(id));
1025 }
1026 assert!(!cache.insert_pending(ids[16]));
1027
1028 assert_eq!(cache.pending_entry_count(), 16);
1029 assert_eq!(cache.len(), 16);
1030 assert!(!cache.contains(&ids[16]));
1031 }
1032
1033 #[test]
1034 fn pending_entry_cap_preserves_loaded_entries() {
1035 let mut cache = TileCache::new(64);
1036 let loaded: Vec<TileId> = (0..6).map(|i| TileId::new(5, i, 0)).collect();
1037 let pending: Vec<TileId> = (10..27).map(|i| TileId::new(5, i, 0)).collect();
1038
1039 for &id in &loaded {
1040 cache.promote(id, dummy_tile_response());
1041 }
1042 for &id in &pending[..16] {
1043 assert!(cache.insert_pending(id));
1044 }
1045
1046 assert!(!cache.insert_pending(pending[16]));
1047 assert_eq!(cache.pending_entry_count(), 16);
1048 for &id in &loaded {
1049 assert!(cache.contains(&id), "loaded tile should be retained when pending cap is hit");
1050 }
1051 }
1052
1053 #[test]
1054 fn late_promotion_bypasses_pending_entry_cap() {
1055 let mut cache = TileCache::new(64);
1056 let late = TileId::new(6, 42, 0);
1057
1058 for i in 0..cache.max_pending_entries() {
1059 assert!(cache.insert_pending(TileId::new(6, i as u32, 0)));
1060 }
1061 assert!(!cache.insert_pending(TileId::new(6, 63, 0)));
1062
1063 cache.promote(late, dummy_tile_response());
1064
1065 assert!(cache.contains(&late));
1066 assert!(cache.get(&late).unwrap().is_loaded());
1067 }
1068
1069 #[test]
1070 fn pending_insert_prefers_evicting_pending_before_loaded() {
1071 let mut cache = TileCache::new(3);
1072 let a = TileId::new(1, 0, 0);
1073 let b = TileId::new(1, 0, 1);
1074 let c = TileId::new(1, 1, 0);
1075
1076 cache.insert_pending(a); cache.insert_pending(b); cache.insert_pending(c); cache.promote(a, dummy_tile_response()); cache.promote(b, dummy_tile_response()); let d = TileId::new(1, 1, 1);
1090 cache.insert_pending(d);
1091 assert!(cache.contains(&a), "'a' should survive (Loaded, still renderable)");
1092 assert!(cache.contains(&b), "'b' should survive (MRU Loaded)");
1093 assert!(!cache.contains(&c), "'c' should be evicted (oldest Pending)");
1094 assert!(cache.contains(&d));
1095 }
1096
1097 #[test]
1098 fn pending_insert_keeps_expired_renderable_tile_over_pending() {
1099 let mut cache = TileCache::new(2);
1100 let expired = TileId::new(4, 0, 0);
1101 let pending = TileId::new(4, 1, 0);
1102 let incoming = TileId::new(4, 2, 0);
1103
1104 cache.promote(expired, expiring_tile_response());
1105 assert!(cache.mark_expired(expired));
1106 assert!(cache.insert_pending(pending));
1107
1108 let result = cache.insert_pending_with_eviction(incoming);
1109
1110 assert!(result.inserted);
1111 assert!(cache.contains(&expired), "expired renderable tile should be retained");
1112 assert!(!cache.contains(&pending), "older pending tile should be evicted first");
1113 assert!(cache.contains(&incoming));
1114 }
1115
1116 #[test]
1117 fn pending_insert_evicts_lru_renderable_as_last_resort() {
1118 let mut cache = TileCache::new(2);
1119 let loaded = TileId::new(5, 0, 0);
1120 let expired = TileId::new(5, 1, 0);
1121 let incoming = TileId::new(5, 2, 0);
1122
1123 cache.promote(loaded, dummy_tile_response());
1124 cache.promote(expired, expiring_tile_response());
1125 assert!(cache.mark_expired(expired));
1126
1127 let result = cache.insert_pending_with_eviction(incoming);
1128
1129 assert!(result.inserted, "pending insert must succeed by evicting LRU renderable");
1130 assert_eq!(result.evicted.len(), 1);
1131 assert_eq!(result.evicted[0].id, loaded, "LRU renderable entry should be evicted");
1132 assert!(cache.contains(&expired));
1133 assert!(cache.contains(&incoming));
1134 }
1135
1136 #[test]
1137 fn pending_insert_evicts_lru_renderable_failed_entry_as_last_resort() {
1138 let mut cache = TileCache::new(2);
1139 let failed_stale = TileId::new(6, 0, 0);
1140 let loaded = TileId::new(6, 1, 0);
1141 let incoming = TileId::new(6, 2, 0);
1142
1143 cache.promote(failed_stale, dummy_tile_response());
1144 cache.mark_failed(failed_stale, &TileError::Network("boom".into()));
1145 cache.promote(loaded, dummy_tile_response());
1146
1147 let result = cache.insert_pending_with_eviction(incoming);
1148
1149 assert!(result.inserted, "pending insert must succeed by evicting LRU renderable");
1150 assert_eq!(result.evicted.len(), 1);
1151 assert_eq!(result.evicted[0].id, failed_stale, "LRU failed-but-renderable entry should be evicted");
1152 assert!(cache.contains(&loaded));
1153 assert!(cache.contains(&incoming));
1154 }
1155
1156 #[test]
1159 fn cancel_reload_demotes_reloading_to_expired() {
1160 let mut cache = TileCache::new(10);
1161 let id = TileId::new(3, 0, 0);
1162 cache.promote(id, expiring_tile_response());
1163 assert!(cache.start_reload(id));
1164 assert!(cache.get(&id).unwrap().is_reloading());
1165
1166 assert!(cache.cancel_reload(&id));
1167
1168 let entry = cache.get(&id).unwrap();
1169 assert!(entry.is_expired());
1170 assert!(entry.is_renderable());
1171 assert!(entry.data().is_some());
1172 }
1173
1174 #[test]
1175 fn cancel_reload_has_no_effect_on_non_reloading() {
1176 let mut cache = TileCache::new(10);
1177 let pending = TileId::new(3, 0, 0);
1178 let loaded = TileId::new(3, 1, 0);
1179 cache.insert_pending(pending);
1180 cache.promote(loaded, dummy_tile_response());
1181
1182 assert!(!cache.cancel_reload(&pending));
1183 assert!(!cache.cancel_reload(&loaded));
1184 assert!(cache.get(&pending).unwrap().is_pending());
1185 assert!(cache.get(&loaded).unwrap().is_loaded());
1186 }
1187
1188 #[test]
1189 fn mark_failed_sets_state() {
1190 let mut cache = TileCache::new(10);
1191 let id = TileId::new(0, 0, 0);
1192 cache.insert_pending(id);
1193 let err = TileError::Network("timeout".into());
1194 cache.mark_failed(id, &err);
1195 let entry = cache.get(&id).unwrap();
1196 assert!(entry.is_failed());
1197 if let TileCacheEntry::Failed { error, .. } = entry {
1198 assert!(error.contains("timeout"));
1199 }
1200 }
1201
1202 #[test]
1203 fn mark_failed_ignores_missing_entry() {
1204 let mut cache = TileCache::new(1);
1205 cache.mark_failed(TileId::new(1, 0, 0), &TileError::Other("boom".into()));
1206 assert!(cache.is_empty());
1207 }
1208
1209 #[test]
1212 fn remove_existing() {
1213 let mut cache = TileCache::new(10);
1214 let id = TileId::new(0, 0, 0);
1215 cache.insert_pending(id);
1216 assert!(cache.remove(&id));
1217 assert!(!cache.contains(&id));
1218 assert!(cache.is_empty());
1219 }
1220
1221 #[test]
1222 fn remove_nonexistent() {
1223 let mut cache = TileCache::new(10);
1224 assert!(!cache.remove(&TileId::new(0, 0, 0)));
1225 }
1226
1227 #[test]
1230 fn clear_empties_cache() {
1231 let mut cache = TileCache::new(10);
1232 cache.insert_pending(TileId::new(0, 0, 0));
1233 cache.insert_pending(TileId::new(1, 0, 0));
1234 cache.promote(TileId::new(1, 0, 0), dummy_tile_response());
1235 assert_eq!(cache.len(), 2);
1236
1237 cache.clear();
1238 assert!(cache.is_empty());
1239 assert_eq!(cache.len(), 0);
1240 assert_eq!(cache.capacity(), 10);
1242 }
1243
1244 #[test]
1247 fn eviction_at_capacity() {
1248 let mut cache = TileCache::new(2);
1249 let a = TileId::new(1, 0, 0);
1250 let b = TileId::new(1, 0, 1);
1251 let c = TileId::new(1, 1, 0);
1252
1253 cache.insert_pending(a);
1254 cache.insert_pending(b);
1255 cache.insert_pending(c);
1256
1257 assert_eq!(cache.len(), 2);
1258 assert!(!cache.contains(&a), "first entry should be evicted");
1259 assert!(cache.contains(&b));
1260 assert!(cache.contains(&c));
1261 }
1262
1263 #[test]
1264 fn eviction_fifo_without_touch() {
1265 let mut cache = TileCache::new(3);
1267 let ids: Vec<TileId> = (0..5).map(|i| TileId::new(4, i, 0)).collect();
1268
1269 for &id in &ids {
1270 cache.insert_pending(id);
1271 }
1272
1273 assert_eq!(cache.len(), 3);
1275 assert!(!cache.contains(&ids[0]));
1276 assert!(!cache.contains(&ids[1]));
1277 assert!(cache.contains(&ids[2]));
1278 assert!(cache.contains(&ids[3]));
1279 assert!(cache.contains(&ids[4]));
1280 }
1281
1282 #[test]
1285 fn entry_state_helpers() {
1286 assert!(TileCacheEntry::Pending.is_pending());
1287 assert!(!TileCacheEntry::Pending.is_loaded());
1288 assert!(!TileCacheEntry::Pending.is_failed());
1289 assert!(TileCacheEntry::Pending.data().is_none());
1290
1291 let loaded = TileCacheEntry::Loaded(CachedTile {
1292 data: dummy_tile_data(),
1293 freshness: TileFreshness::default(),
1294 loaded_at: SystemTime::now(),
1295 });
1296 assert!(!loaded.is_pending());
1297 assert!(loaded.is_loaded());
1298 assert!(!loaded.is_failed());
1299 assert!(loaded.data().is_some());
1300
1301 let failed = TileCacheEntry::Failed {
1302 error: "err".into(),
1303 stale: None,
1304 };
1305 assert!(!failed.is_pending());
1306 assert!(!failed.is_loaded());
1307 assert!(failed.is_failed());
1308 assert!(failed.data().is_none());
1309 }
1310
1311 #[test]
1312 fn expired_tile_is_renderable_and_refreshable() {
1313 let mut cache = TileCache::new(4);
1314 let id = TileId::new(1, 0, 0);
1315 cache.promote(id, expiring_tile_response());
1316
1317 assert_eq!(cache.expired_ids(), vec![id]);
1318 assert!(cache.mark_expired(id));
1319 assert!(cache.get(&id).unwrap().is_expired());
1320 assert!(cache.get(&id).unwrap().is_renderable());
1321 assert!(cache.start_reload(id));
1322 assert!(cache.get(&id).unwrap().is_reloading());
1323 }
1324
1325 #[test]
1326 fn failed_reload_keeps_stale_payload_renderable() {
1327 let mut cache = TileCache::new(4);
1328 let id = TileId::new(1, 0, 0);
1329 cache.promote(id, dummy_tile_response());
1330 assert!(cache.start_reload(id));
1331
1332 cache.mark_failed(id, &TileError::Network("timeout".into()));
1333 let entry = cache.get(&id).unwrap();
1334 assert!(entry.is_expired());
1335 assert!(entry.is_renderable());
1336 }
1337
1338 #[test]
1341 fn lru_evicts_least_recently_used_first() {
1342 let mut cache = TileCache::new(3);
1343 let a = TileId::new(0, 0, 0);
1344 let b = TileId::new(1, 0, 0);
1345 let c = TileId::new(2, 0, 0);
1346 cache.insert_pending(a);
1347 cache.insert_pending(b);
1348 cache.insert_pending(c);
1349
1350 cache.touch(&a);
1352
1353 let d = TileId::new(3, 0, 0);
1354 cache.insert_pending(d);
1355
1356 assert!(!cache.contains(&b), "b should be evicted as LRU");
1358 assert!(cache.contains(&a));
1359 assert!(cache.contains(&c));
1360 assert!(cache.contains(&d));
1361 }
1362
1363 #[test]
1364 fn lru_touch_moves_to_mru_end() {
1365 let mut cache = TileCache::new(2);
1366 let a = TileId::new(0, 0, 0);
1367 let b = TileId::new(1, 0, 0);
1368 cache.insert_pending(a);
1369 cache.insert_pending(b);
1370
1371 cache.touch(&a);
1373
1374 let c = TileId::new(2, 0, 0);
1375 cache.insert_pending(c);
1376
1377 assert!(!cache.contains(&b), "b should be evicted");
1378 assert!(cache.contains(&a));
1379 assert!(cache.contains(&c));
1380 }
1381
1382 #[test]
1383 fn lru_remove_is_o1() {
1384 let mut cache = TileCache::new(5);
1385 for i in 0..5 {
1386 cache.insert_pending(TileId::new(i, 0, 0));
1387 }
1388
1389 let mid = TileId::new(2, 0, 0);
1391 assert!(cache.remove(&mid));
1392 assert!(!cache.contains(&mid));
1393 assert_eq!(cache.len(), 4);
1394
1395 cache.insert_pending(TileId::new(5, 0, 0));
1397 assert_eq!(cache.len(), 5);
1398 }
1399
1400 #[test]
1403 fn refresh_ttl_updates_freshness_without_replacing_data() {
1404 let mut cache = TileCache::new(4);
1405 let id = TileId::new(1, 0, 0);
1406 cache.insert_pending(id);
1407 cache.promote(id, expiring_tile_response());
1408
1409 assert!(cache.start_reload(id));
1411 assert!(cache.get(&id).unwrap().is_reloading());
1412
1413 let new_freshness = TileFreshness {
1415 expires_at: Some(SystemTime::now() + Duration::from_secs(3600)),
1416 etag: Some("etag-2".into()),
1417 last_modified: None,
1418 };
1419 assert!(cache.refresh_ttl(id, new_freshness.clone()));
1420
1421 let entry = cache.get(&id).unwrap();
1422 assert!(entry.is_loaded(), "should be back to Loaded state");
1423 assert!(!entry.is_expired());
1424
1425 let freshness = entry.freshness().unwrap();
1426 assert_eq!(freshness.etag.as_deref(), Some("etag-2"));
1427 }
1428
1429 #[test]
1430 fn refresh_ttl_returns_false_for_pending_entry() {
1431 let mut cache = TileCache::new(4);
1432 let id = TileId::new(1, 0, 0);
1433 cache.insert_pending(id);
1434
1435 let freshness = TileFreshness::default();
1436 assert!(!cache.refresh_ttl(id, freshness));
1437 }
1438
1439 #[test]
1442 fn revalidation_hint_extracts_etag_and_last_modified() {
1443 let mut cache = TileCache::new(4);
1444 let id = TileId::new(1, 0, 0);
1445 cache.insert_pending(id);
1446 cache.promote(id, expiring_tile_response());
1447
1448 let hint = cache.revalidation_hint(&id).expect("hint for loaded tile");
1449 assert_eq!(hint.etag.as_deref(), Some("etag-1"));
1450 assert!(hint.has_validators());
1451 }
1452
1453 #[test]
1454 fn revalidation_hint_returns_none_for_missing_tile() {
1455 let cache = TileCache::new(4);
1456 let id = TileId::new(1, 0, 0);
1457 assert!(cache.revalidation_hint(&id).is_none());
1458 }
1459
1460 #[test]
1463 fn byte_budget_evicts_when_exceeded() {
1464 let tile_bytes = 256 * 256 * 4;
1466 let mut cache = TileCache::with_byte_budget(10, tile_bytes * 2 + 100);
1468 assert_eq!(cache.max_bytes(), Some(tile_bytes * 2 + 100));
1469
1470 let a = TileId::new(0, 0, 0);
1471 let b = TileId::new(1, 0, 0);
1472 let c = TileId::new(2, 0, 0);
1473
1474 cache.insert_pending(a);
1475 cache.promote(a, dummy_tile_response());
1476 assert_eq!(cache.total_bytes(), tile_bytes);
1477
1478 cache.insert_pending(b);
1479 cache.promote(b, dummy_tile_response());
1480 assert_eq!(cache.total_bytes(), tile_bytes * 2);
1481
1482 cache.insert_pending(c);
1484 cache.promote(c, dummy_tile_response());
1485 assert!(!cache.contains(&a), "a should be evicted by byte budget");
1486 assert!(cache.contains(&b));
1487 assert!(cache.contains(&c));
1488 assert!(cache.total_bytes() <= tile_bytes * 2 + 100);
1489 }
1490
1491 #[test]
1492 fn byte_budget_tracks_removal() {
1493 let tile_bytes = 256 * 256 * 4;
1494 let mut cache = TileCache::with_byte_budget(10, tile_bytes * 10);
1495
1496 let id = TileId::new(0, 0, 0);
1497 cache.insert_pending(id);
1498 cache.promote(id, dummy_tile_response());
1499 assert_eq!(cache.total_bytes(), tile_bytes);
1500
1501 cache.remove(&id);
1502 assert_eq!(cache.total_bytes(), 0);
1503 }
1504}