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!(
219 self,
220 Self::Loaded(_) | Self::Expired(_) | Self::Reloading(_)
221 )
222 }
223
224 #[inline]
226 pub fn is_expired(&self) -> bool {
227 matches!(self, Self::Expired(_))
228 }
229
230 #[inline]
232 pub fn is_reloading(&self) -> bool {
233 matches!(self, Self::Reloading(_))
234 }
235
236 #[inline]
238 pub fn is_failed(&self) -> bool {
239 matches!(self, Self::Failed { .. })
240 }
241
242 #[inline]
244 pub fn is_renderable(&self) -> bool {
245 self.data().is_some()
246 }
247
248 #[inline]
250 pub fn data(&self) -> Option<&TileData> {
251 self.cached_tile().map(|tile| &tile.data)
252 }
253
254 #[inline]
256 pub fn cached_tile(&self) -> Option<&CachedTile> {
257 match self {
258 Self::Loaded(tile) | Self::Expired(tile) | Self::Reloading(tile) => Some(tile),
259 Self::Failed { stale, .. } => stale.as_ref(),
260 Self::Pending => None,
261 }
262 }
263
264 #[inline]
266 pub fn freshness(&self) -> Option<&TileFreshness> {
267 self.cached_tile().map(|tile| &tile.freshness)
268 }
269
270 #[inline]
274 pub fn loaded_at(&self) -> Option<SystemTime> {
275 self.cached_tile().map(|tile| tile.loaded_at)
276 }
277}
278
279pub struct TileCache {
288 entries: HashMap<TileId, (TileCacheEntry, LruLink)>,
290 lru_head: Option<TileId>,
292 lru_tail: Option<TileId>,
294 max_entries: usize,
296 max_bytes: Option<usize>,
299 total_bytes: usize,
301}
302
303impl TileCache {
304 pub fn new(max_entries: usize) -> Self {
310 Self {
311 entries: HashMap::with_capacity(max_entries),
312 lru_head: None,
313 lru_tail: None,
314 max_entries,
315 max_bytes: None,
316 total_bytes: 0,
317 }
318 }
319
320 pub fn with_byte_budget(max_entries: usize, max_bytes: usize) -> Self {
324 Self {
325 entries: HashMap::with_capacity(max_entries),
326 lru_head: None,
327 lru_tail: None,
328 max_entries,
329 max_bytes: Some(max_bytes),
330 total_bytes: 0,
331 }
332 }
333
334 #[inline]
341 pub fn get(&self, id: &TileId) -> Option<&TileCacheEntry> {
342 self.entries.get(id).map(|(entry, _)| entry)
343 }
344
345 #[inline]
347 pub fn contains(&self, id: &TileId) -> bool {
348 self.entries.contains_key(id)
349 }
350
351 #[inline]
353 pub fn len(&self) -> usize {
354 self.entries.len()
355 }
356
357 #[inline]
359 pub fn is_empty(&self) -> bool {
360 self.entries.is_empty()
361 }
362
363 #[inline]
365 pub fn capacity(&self) -> usize {
366 self.max_entries
367 }
368
369 #[inline]
371 fn max_pending_entries(&self) -> usize {
372 if self.max_entries == 0 {
373 0
374 } else if self.max_entries < PENDING_ENTRY_CAP_MIN_CACHE_SIZE {
375 usize::MAX
376 } else {
377 (self.max_entries / PENDING_ENTRY_CAP_DIVISOR).max(1)
378 }
379 }
380
381 #[inline]
383 fn pending_entry_count(&self) -> usize {
384 self.entries
385 .values()
386 .filter(|(entry, _)| entry.is_pending())
387 .count()
388 }
389
390 #[inline]
392 pub fn total_bytes(&self) -> usize {
393 self.total_bytes
394 }
395
396 #[inline]
398 pub fn max_bytes(&self) -> Option<usize> {
399 self.max_bytes
400 }
401
402 pub fn pending_ids(&self) -> Vec<TileId> {
404 self.entries
405 .iter()
406 .filter_map(|(id, (entry, _))| entry.is_pending().then_some(*id))
407 .collect()
408 }
409
410 pub fn inflight_ids(&self) -> Vec<TileId> {
412 self.entries
413 .iter()
414 .filter_map(|(id, (entry, _))| match entry {
415 TileCacheEntry::Pending | TileCacheEntry::Reloading(_) => Some(*id),
416 _ => None,
417 })
418 .collect()
419 }
420
421 pub fn expired_ids_at(&self, now: SystemTime) -> Vec<TileId> {
423 self.entries
424 .iter()
425 .filter_map(|(id, (entry, _))| match entry {
426 TileCacheEntry::Loaded(tile) if tile.is_expired_at(now) => Some(*id),
427 TileCacheEntry::Expired(_) => Some(*id),
428 _ => None,
429 })
430 .collect()
431 }
432
433 pub fn expired_ids(&self) -> Vec<TileId> {
435 self.expired_ids_at(SystemTime::now())
436 }
437
438 pub fn stats(&self) -> TileCacheStats {
440 let mut stats = TileCacheStats {
441 total_entries: self.entries.len(),
442 ..TileCacheStats::default()
443 };
444
445 for (entry, _) in self.entries.values() {
446 match entry {
447 TileCacheEntry::Pending => stats.pending_entries += 1,
448 TileCacheEntry::Loaded(_) => stats.loaded_entries += 1,
449 TileCacheEntry::Expired(_) => stats.expired_entries += 1,
450 TileCacheEntry::Reloading(_) => stats.reloading_entries += 1,
451 TileCacheEntry::Failed { .. } => stats.failed_entries += 1,
452 }
453 if entry.is_renderable() {
454 stats.renderable_entries += 1;
455 }
456 }
457
458 stats
459 }
460
461 fn lru_unlink(&mut self, id: &TileId) {
466 let Some((_, link)) = self.entries.get(id) else {
467 return;
468 };
469 let prev = link.prev;
470 let next = link.next;
471
472 if let Some(p) = prev {
473 if let Some((_, plink)) = self.entries.get_mut(&p) {
474 plink.next = next;
475 }
476 } else {
477 self.lru_head = next;
478 }
479
480 if let Some(n) = next {
481 if let Some((_, nlink)) = self.entries.get_mut(&n) {
482 nlink.prev = prev;
483 }
484 } else {
485 self.lru_tail = prev;
486 }
487
488 if let Some((_, link)) = self.entries.get_mut(id) {
490 link.prev = None;
491 link.next = None;
492 }
493 }
494
495 fn lru_push_back(&mut self, id: &TileId) {
497 if let Some(old_tail) = self.lru_tail {
498 if let Some((_, tlink)) = self.entries.get_mut(&old_tail) {
499 tlink.next = Some(*id);
500 }
501 if let Some((_, link)) = self.entries.get_mut(id) {
502 link.prev = Some(old_tail);
503 link.next = None;
504 }
505 self.lru_tail = Some(*id);
506 } else {
507 if let Some((_, link)) = self.entries.get_mut(id) {
509 link.prev = None;
510 link.next = None;
511 }
512 self.lru_head = Some(*id);
513 self.lru_tail = Some(*id);
514 }
515 }
516
517 pub fn touch(&mut self, id: &TileId) -> bool {
523 if !self.entries.contains_key(id) {
524 return false;
525 }
526 self.lru_unlink(id);
527 self.lru_push_back(id);
528 true
529 }
530
531 pub fn insert_pending_with_eviction(&mut self, id: TileId) -> InsertPendingResult {
534 if self.max_entries == 0 || self.entries.contains_key(&id) {
535 return InsertPendingResult::default();
536 }
537
538 if self.pending_entry_count() >= self.max_pending_entries() {
539 return InsertPendingResult::default();
540 }
541
542 let evicted = self.evict_if_full_for_pending_insert();
543 if self.entries.len() >= self.max_entries {
544 return InsertPendingResult::default();
545 }
546 self.entries.insert(
547 id,
548 (
549 TileCacheEntry::Pending,
550 LruLink {
551 prev: None,
552 next: None,
553 },
554 ),
555 );
556 self.lru_push_back(&id);
557
558 InsertPendingResult {
559 inserted: true,
560 evicted,
561 }
562 }
563
564 pub fn insert_pending(&mut self, id: TileId) -> bool {
568 self.insert_pending_with_eviction(id).inserted
569 }
570
571 pub fn promote_with_eviction(
574 &mut self,
575 id: TileId,
576 response: TileResponse,
577 ) -> Vec<EvictedTile> {
578 if self.max_entries == 0 {
579 return Vec::new();
580 }
581
582 let byte_len = response.data.byte_len();
583
584 let evicted = if !self.entries.contains_key(&id) {
585 let evicted = self.evict_if_full();
586 self.entries.insert(
587 id,
588 (
589 TileCacheEntry::Pending,
590 LruLink {
591 prev: None,
592 next: None,
593 },
594 ),
595 );
596 self.lru_push_back(&id);
597 evicted
598 } else {
599 if let Some((old_entry, _)) = self.entries.get(&id) {
601 if let Some(data) = old_entry.data() {
602 self.total_bytes = self.total_bytes.saturating_sub(data.byte_len());
603 }
604 }
605 self.touch(&id);
606 Vec::new()
607 };
608
609 #[allow(clippy::unwrap_used)] {
611 self.entries.get_mut(&id).unwrap().0 = TileCacheEntry::Loaded(CachedTile {
612 data: response.data,
613 freshness: response.freshness,
614 loaded_at: SystemTime::now(),
615 });
616 }
617 self.total_bytes += byte_len;
618
619 let mut extra_evicted = Vec::new();
622 if let Some(max) = self.max_bytes {
623 while self.total_bytes > max && self.entries.len() > 1 {
624 if let Some(old) = self.lru_head {
625 if old == id {
626 break; }
628 if let Some(ev) = self.remove_and_return(old) {
629 extra_evicted.push(ev);
630 }
631 } else {
632 break;
633 }
634 }
635 }
636
637 if extra_evicted.is_empty() {
638 evicted
639 } else {
640 let mut all = evicted;
641 all.extend(extra_evicted);
642 all
643 }
644 }
645
646 pub fn promote(&mut self, id: TileId, response: TileResponse) {
649 let _ = self.promote_with_eviction(id, response);
650 }
651
652 pub fn mark_expired(&mut self, id: TileId) -> bool {
654 let Some((entry, _)) = self.entries.get_mut(&id) else {
655 return false;
656 };
657
658 match entry {
659 TileCacheEntry::Loaded(tile) => {
660 let tile = tile.clone();
661 *entry = TileCacheEntry::Expired(tile);
662 true
663 }
664 TileCacheEntry::Expired(_) | TileCacheEntry::Reloading(_) => true,
665 TileCacheEntry::Pending | TileCacheEntry::Failed { .. } => false,
666 }
667 }
668
669 pub fn start_reload(&mut self, id: TileId) -> bool {
671 let transitioned = match self.entries.get_mut(&id) {
672 Some((TileCacheEntry::Loaded(tile), _)) | Some((TileCacheEntry::Expired(tile), _)) => {
673 let tile = tile.clone();
674 self.entries.get_mut(&id).expect("entry should exist").0 =
675 TileCacheEntry::Reloading(tile);
676 true
677 }
678 _ => false,
679 };
680
681 if transitioned {
682 self.touch(&id);
683 }
684
685 transitioned
686 }
687
688 pub fn refresh_ttl(&mut self, id: TileId, freshness: TileFreshness) -> bool {
693 let Some((entry, _)) = self.entries.get_mut(&id) else {
694 return false;
695 };
696
697 match entry {
698 TileCacheEntry::Reloading(tile) | TileCacheEntry::Expired(tile) => {
699 tile.freshness = freshness;
700 let tile = tile.clone();
701 *entry = TileCacheEntry::Loaded(tile);
702 self.touch(&id);
703 true
704 }
705 TileCacheEntry::Loaded(tile) => {
706 tile.freshness = freshness;
707 true
708 }
709 TileCacheEntry::Pending | TileCacheEntry::Failed { .. } => false,
710 }
711 }
712
713 pub fn revalidation_hint(&self, id: &TileId) -> Option<crate::tile_source::RevalidationHint> {
715 let (entry, _) = self.entries.get(id)?;
716 let freshness = entry.freshness()?;
717 Some(crate::tile_source::RevalidationHint {
718 etag: freshness.etag.clone(),
719 last_modified: freshness.last_modified.clone(),
720 })
721 }
722
723 pub fn mark_failed(&mut self, id: TileId, error: &TileError) {
725 if let Some((entry, _)) = self.entries.get_mut(&id) {
726 match entry {
727 TileCacheEntry::Reloading(tile) | TileCacheEntry::Expired(tile) => {
728 let tile = tile.clone();
729 *entry = TileCacheEntry::Expired(tile);
730 }
731 TileCacheEntry::Loaded(_)
732 | TileCacheEntry::Pending
733 | TileCacheEntry::Failed { .. } => {
734 let stale = match entry {
735 TileCacheEntry::Loaded(tile) => Some(tile.clone()),
736 TileCacheEntry::Failed { stale, .. } => stale.clone(),
737 _ => None,
738 };
739 *entry = TileCacheEntry::Failed {
740 error: error.to_string(),
741 stale,
742 };
743 }
744 }
745 }
746 }
747
748 pub fn cancel_reload(&mut self, id: &TileId) -> bool {
754 let Some((entry, _)) = self.entries.get_mut(id) else {
755 return false;
756 };
757 match entry {
758 TileCacheEntry::Reloading(tile) => {
759 let tile = tile.clone();
760 *entry = TileCacheEntry::Expired(tile);
761 true
762 }
763 _ => false,
764 }
765 }
766
767 pub fn remove(&mut self, id: &TileId) -> bool {
771 self.remove_and_return(*id).is_some()
772 }
773
774 pub fn clear(&mut self) {
776 for (_, (entry, _)) in self.entries.drain() {
777 if let Some(data) = entry.data() {
778 self.total_bytes = self.total_bytes.saturating_sub(data.byte_len());
779 }
780 }
781 self.lru_head = None;
782 self.lru_tail = None;
783 self.total_bytes = 0;
784 }
785
786 fn remove_and_return(&mut self, id: TileId) -> Option<EvictedTile> {
790 self.lru_unlink(&id);
791 if let Some((entry, _)) = self.entries.remove(&id) {
792 if let Some(data) = entry.data() {
793 self.total_bytes = self.total_bytes.saturating_sub(data.byte_len());
794 }
795 Some(EvictedTile { id, entry })
796 } else {
797 None
798 }
799 }
800
801 fn evict_if_full(&mut self) -> Vec<EvictedTile> {
809 let mut evicted = Vec::new();
810 while self.entries.len() >= self.max_entries {
811 let victim = self
818 .find_failed_lru()
819 .or_else(|| self.find_non_pending_lru())
820 .or(self.lru_head);
821 if let Some(id) = victim {
822 if let Some(ev) = self.remove_and_return(id) {
823 evicted.push(ev);
824 }
825 } else {
826 break;
827 }
828 }
829 evicted
830 }
831
832 fn evict_if_full_for_pending_insert(&mut self) -> Vec<EvictedTile> {
843 let mut evicted = Vec::new();
844 while self.entries.len() >= self.max_entries {
845 let victim = self
846 .find_non_renderable_lru()
847 .or_else(|| self.find_pending_lru())
848 .or_else(|| self.find_non_renderable_non_pending_lru())
849 .or(self.lru_head);
850 if let Some(id) = victim {
851 if let Some(ev) = self.remove_and_return(id) {
852 evicted.push(ev);
853 }
854 } else {
855 break;
856 }
857 }
858 evicted
859 }
860
861 fn find_failed_lru(&self) -> Option<TileId> {
863 let mut cursor = self.lru_head;
864 while let Some(id) = cursor {
865 if let Some((entry, link)) = self.entries.get(&id) {
866 if entry.is_failed() {
867 return Some(id);
868 }
869 cursor = link.next;
870 } else {
871 break;
872 }
873 }
874 None
875 }
876
877 fn find_non_renderable_lru(&self) -> Option<TileId> {
879 let mut cursor = self.lru_head;
880 while let Some(id) = cursor {
881 if let Some((entry, link)) = self.entries.get(&id) {
882 if !entry.is_renderable() {
883 return Some(id);
884 }
885 cursor = link.next;
886 } else {
887 break;
888 }
889 }
890 None
891 }
892
893 fn find_non_renderable_non_pending_lru(&self) -> Option<TileId> {
896 let mut cursor = self.lru_head;
897 while let Some(id) = cursor {
898 if let Some((entry, link)) = self.entries.get(&id) {
899 if !entry.is_pending() && !entry.is_renderable() {
900 return Some(id);
901 }
902 cursor = link.next;
903 } else {
904 break;
905 }
906 }
907 None
908 }
909
910 fn find_pending_lru(&self) -> Option<TileId> {
912 let mut cursor = self.lru_head;
913 while let Some(id) = cursor {
914 if let Some((entry, link)) = self.entries.get(&id) {
915 if entry.is_pending() {
916 return Some(id);
917 }
918 cursor = link.next;
919 } else {
920 break;
921 }
922 }
923 None
924 }
925
926 fn find_non_pending_lru(&self) -> Option<TileId> {
932 let mut cursor = self.lru_head;
933 while let Some(id) = cursor {
934 if let Some((entry, link)) = self.entries.get(&id) {
935 if !entry.is_pending() {
936 return Some(id);
937 }
938 cursor = link.next;
939 } else {
940 break;
941 }
942 }
943 None
944 }
945}
946
947#[cfg(test)]
952mod tests {
953 use super::*;
954 use crate::tile_source::{DecodedImage, TileResponse};
955 use std::time::Duration;
956
957 fn dummy_tile_data() -> TileData {
958 TileData::Raster(DecodedImage {
959 width: 256,
960 height: 256,
961 data: vec![0u8; 256 * 256 * 4].into(),
962 })
963 }
964
965 fn dummy_tile_response() -> TileResponse {
966 TileResponse::from_data(dummy_tile_data())
967 }
968
969 fn expiring_tile_response() -> TileResponse {
970 TileResponse::from_data(dummy_tile_data()).with_freshness(TileFreshness {
971 expires_at: Some(SystemTime::now() - Duration::from_secs(1)),
972 etag: Some("etag-1".into()),
973 last_modified: None,
974 })
975 }
976
977 #[test]
980 fn new_cache_is_empty() {
981 let cache = TileCache::new(10);
982 assert!(cache.is_empty());
983 assert_eq!(cache.len(), 0);
984 assert_eq!(cache.capacity(), 10);
985 }
986
987 #[test]
988 fn zero_capacity_cache() {
989 let mut cache = TileCache::new(0);
990 let result = cache.insert_pending_with_eviction(TileId::new(0, 0, 0));
991 assert!(!result.inserted);
992 assert!(result.evicted.is_empty());
993 assert!(cache.is_empty());
994 }
995
996 #[test]
999 fn insert_and_get() {
1000 let mut cache = TileCache::new(10);
1001 let id = TileId::new(0, 0, 0);
1002 assert!(cache.insert_pending(id));
1003 assert!(cache.contains(&id));
1004 assert!(cache.get(&id).unwrap().is_pending());
1005 }
1006
1007 #[test]
1008 fn no_double_insert() {
1009 let mut cache = TileCache::new(10);
1010 let id = TileId::new(0, 0, 0);
1011 assert!(cache.insert_pending(id));
1012 assert!(!cache.insert_pending(id));
1013 assert_eq!(cache.len(), 1);
1014 }
1015
1016 #[test]
1017 fn insert_does_not_overwrite_loaded() {
1018 let mut cache = TileCache::new(10);
1019 let id = TileId::new(0, 0, 0);
1020 cache.insert_pending(id);
1021 cache.promote(id, dummy_tile_response());
1022 assert!(!cache.insert_pending(id));
1024 assert!(cache.get(&id).unwrap().is_loaded());
1025 }
1026
1027 #[test]
1030 fn promote_to_loaded() {
1031 let mut cache = TileCache::new(10);
1032 let id = TileId::new(0, 0, 0);
1033 cache.insert_pending(id);
1034 cache.promote(id, dummy_tile_response());
1035 let entry = cache.get(&id).unwrap();
1036 assert!(entry.is_loaded());
1037 assert!(entry.data().is_some());
1038 }
1039
1040 #[test]
1041 fn promote_without_prior_pending() {
1042 let mut cache = TileCache::new(10);
1045 let id = TileId::new(5, 10, 10);
1046 cache.promote(id, dummy_tile_response());
1047 assert!(cache.contains(&id));
1048 assert!(cache.get(&id).unwrap().is_loaded());
1049 }
1050
1051 #[test]
1052 fn pending_entry_cap_rejects_excess_pending_inserts() {
1053 let mut cache = TileCache::new(64);
1054 let ids: Vec<TileId> = (0..17).map(|i| TileId::new(5, i, 0)).collect();
1055
1056 assert_eq!(cache.max_pending_entries(), 16);
1057 for &id in &ids[..16] {
1058 assert!(cache.insert_pending(id));
1059 }
1060 assert!(!cache.insert_pending(ids[16]));
1061
1062 assert_eq!(cache.pending_entry_count(), 16);
1063 assert_eq!(cache.len(), 16);
1064 assert!(!cache.contains(&ids[16]));
1065 }
1066
1067 #[test]
1068 fn pending_entry_cap_preserves_loaded_entries() {
1069 let mut cache = TileCache::new(64);
1070 let loaded: Vec<TileId> = (0..6).map(|i| TileId::new(5, i, 0)).collect();
1071 let pending: Vec<TileId> = (10..27).map(|i| TileId::new(5, i, 0)).collect();
1072
1073 for &id in &loaded {
1074 cache.promote(id, dummy_tile_response());
1075 }
1076 for &id in &pending[..16] {
1077 assert!(cache.insert_pending(id));
1078 }
1079
1080 assert!(!cache.insert_pending(pending[16]));
1081 assert_eq!(cache.pending_entry_count(), 16);
1082 for &id in &loaded {
1083 assert!(
1084 cache.contains(&id),
1085 "loaded tile should be retained when pending cap is hit"
1086 );
1087 }
1088 }
1089
1090 #[test]
1091 fn late_promotion_bypasses_pending_entry_cap() {
1092 let mut cache = TileCache::new(64);
1093 let late = TileId::new(6, 42, 0);
1094
1095 for i in 0..cache.max_pending_entries() {
1096 assert!(cache.insert_pending(TileId::new(6, i as u32, 0)));
1097 }
1098 assert!(!cache.insert_pending(TileId::new(6, 63, 0)));
1099
1100 cache.promote(late, dummy_tile_response());
1101
1102 assert!(cache.contains(&late));
1103 assert!(cache.get(&late).unwrap().is_loaded());
1104 }
1105
1106 #[test]
1107 fn pending_insert_prefers_evicting_pending_before_loaded() {
1108 let mut cache = TileCache::new(3);
1109 let a = TileId::new(1, 0, 0);
1110 let b = TileId::new(1, 0, 1);
1111 let c = TileId::new(1, 1, 0);
1112
1113 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);
1127 cache.insert_pending(d);
1128 assert!(
1129 cache.contains(&a),
1130 "'a' should survive (Loaded, still renderable)"
1131 );
1132 assert!(cache.contains(&b), "'b' should survive (MRU Loaded)");
1133 assert!(
1134 !cache.contains(&c),
1135 "'c' should be evicted (oldest Pending)"
1136 );
1137 assert!(cache.contains(&d));
1138 }
1139
1140 #[test]
1141 fn pending_insert_keeps_expired_renderable_tile_over_pending() {
1142 let mut cache = TileCache::new(2);
1143 let expired = TileId::new(4, 0, 0);
1144 let pending = TileId::new(4, 1, 0);
1145 let incoming = TileId::new(4, 2, 0);
1146
1147 cache.promote(expired, expiring_tile_response());
1148 assert!(cache.mark_expired(expired));
1149 assert!(cache.insert_pending(pending));
1150
1151 let result = cache.insert_pending_with_eviction(incoming);
1152
1153 assert!(result.inserted);
1154 assert!(
1155 cache.contains(&expired),
1156 "expired renderable tile should be retained"
1157 );
1158 assert!(
1159 !cache.contains(&pending),
1160 "older pending tile should be evicted first"
1161 );
1162 assert!(cache.contains(&incoming));
1163 }
1164
1165 #[test]
1166 fn pending_insert_evicts_lru_renderable_as_last_resort() {
1167 let mut cache = TileCache::new(2);
1168 let loaded = TileId::new(5, 0, 0);
1169 let expired = TileId::new(5, 1, 0);
1170 let incoming = TileId::new(5, 2, 0);
1171
1172 cache.promote(loaded, dummy_tile_response());
1173 cache.promote(expired, expiring_tile_response());
1174 assert!(cache.mark_expired(expired));
1175
1176 let result = cache.insert_pending_with_eviction(incoming);
1177
1178 assert!(
1179 result.inserted,
1180 "pending insert must succeed by evicting LRU renderable"
1181 );
1182 assert_eq!(result.evicted.len(), 1);
1183 assert_eq!(
1184 result.evicted[0].id, loaded,
1185 "LRU renderable entry should be evicted"
1186 );
1187 assert!(cache.contains(&expired));
1188 assert!(cache.contains(&incoming));
1189 }
1190
1191 #[test]
1192 fn pending_insert_evicts_lru_renderable_failed_entry_as_last_resort() {
1193 let mut cache = TileCache::new(2);
1194 let failed_stale = TileId::new(6, 0, 0);
1195 let loaded = TileId::new(6, 1, 0);
1196 let incoming = TileId::new(6, 2, 0);
1197
1198 cache.promote(failed_stale, dummy_tile_response());
1199 cache.mark_failed(failed_stale, &TileError::Network("boom".into()));
1200 cache.promote(loaded, dummy_tile_response());
1201
1202 let result = cache.insert_pending_with_eviction(incoming);
1203
1204 assert!(
1205 result.inserted,
1206 "pending insert must succeed by evicting LRU renderable"
1207 );
1208 assert_eq!(result.evicted.len(), 1);
1209 assert_eq!(
1210 result.evicted[0].id, failed_stale,
1211 "LRU failed-but-renderable entry should be evicted"
1212 );
1213 assert!(cache.contains(&loaded));
1214 assert!(cache.contains(&incoming));
1215 }
1216
1217 #[test]
1220 fn cancel_reload_demotes_reloading_to_expired() {
1221 let mut cache = TileCache::new(10);
1222 let id = TileId::new(3, 0, 0);
1223 cache.promote(id, expiring_tile_response());
1224 assert!(cache.start_reload(id));
1225 assert!(cache.get(&id).unwrap().is_reloading());
1226
1227 assert!(cache.cancel_reload(&id));
1228
1229 let entry = cache.get(&id).unwrap();
1230 assert!(entry.is_expired());
1231 assert!(entry.is_renderable());
1232 assert!(entry.data().is_some());
1233 }
1234
1235 #[test]
1236 fn cancel_reload_has_no_effect_on_non_reloading() {
1237 let mut cache = TileCache::new(10);
1238 let pending = TileId::new(3, 0, 0);
1239 let loaded = TileId::new(3, 1, 0);
1240 cache.insert_pending(pending);
1241 cache.promote(loaded, dummy_tile_response());
1242
1243 assert!(!cache.cancel_reload(&pending));
1244 assert!(!cache.cancel_reload(&loaded));
1245 assert!(cache.get(&pending).unwrap().is_pending());
1246 assert!(cache.get(&loaded).unwrap().is_loaded());
1247 }
1248
1249 #[test]
1250 fn mark_failed_sets_state() {
1251 let mut cache = TileCache::new(10);
1252 let id = TileId::new(0, 0, 0);
1253 cache.insert_pending(id);
1254 let err = TileError::Network("timeout".into());
1255 cache.mark_failed(id, &err);
1256 let entry = cache.get(&id).unwrap();
1257 assert!(entry.is_failed());
1258 if let TileCacheEntry::Failed { error, .. } = entry {
1259 assert!(error.contains("timeout"));
1260 }
1261 }
1262
1263 #[test]
1264 fn mark_failed_ignores_missing_entry() {
1265 let mut cache = TileCache::new(1);
1266 cache.mark_failed(TileId::new(1, 0, 0), &TileError::Other("boom".into()));
1267 assert!(cache.is_empty());
1268 }
1269
1270 #[test]
1273 fn remove_existing() {
1274 let mut cache = TileCache::new(10);
1275 let id = TileId::new(0, 0, 0);
1276 cache.insert_pending(id);
1277 assert!(cache.remove(&id));
1278 assert!(!cache.contains(&id));
1279 assert!(cache.is_empty());
1280 }
1281
1282 #[test]
1283 fn remove_nonexistent() {
1284 let mut cache = TileCache::new(10);
1285 assert!(!cache.remove(&TileId::new(0, 0, 0)));
1286 }
1287
1288 #[test]
1291 fn clear_empties_cache() {
1292 let mut cache = TileCache::new(10);
1293 cache.insert_pending(TileId::new(0, 0, 0));
1294 cache.insert_pending(TileId::new(1, 0, 0));
1295 cache.promote(TileId::new(1, 0, 0), dummy_tile_response());
1296 assert_eq!(cache.len(), 2);
1297
1298 cache.clear();
1299 assert!(cache.is_empty());
1300 assert_eq!(cache.len(), 0);
1301 assert_eq!(cache.capacity(), 10);
1303 }
1304
1305 #[test]
1308 fn eviction_at_capacity() {
1309 let mut cache = TileCache::new(2);
1310 let a = TileId::new(1, 0, 0);
1311 let b = TileId::new(1, 0, 1);
1312 let c = TileId::new(1, 1, 0);
1313
1314 cache.insert_pending(a);
1315 cache.insert_pending(b);
1316 cache.insert_pending(c);
1317
1318 assert_eq!(cache.len(), 2);
1319 assert!(!cache.contains(&a), "first entry should be evicted");
1320 assert!(cache.contains(&b));
1321 assert!(cache.contains(&c));
1322 }
1323
1324 #[test]
1325 fn eviction_fifo_without_touch() {
1326 let mut cache = TileCache::new(3);
1328 let ids: Vec<TileId> = (0..5).map(|i| TileId::new(4, i, 0)).collect();
1329
1330 for &id in &ids {
1331 cache.insert_pending(id);
1332 }
1333
1334 assert_eq!(cache.len(), 3);
1336 assert!(!cache.contains(&ids[0]));
1337 assert!(!cache.contains(&ids[1]));
1338 assert!(cache.contains(&ids[2]));
1339 assert!(cache.contains(&ids[3]));
1340 assert!(cache.contains(&ids[4]));
1341 }
1342
1343 #[test]
1346 fn entry_state_helpers() {
1347 assert!(TileCacheEntry::Pending.is_pending());
1348 assert!(!TileCacheEntry::Pending.is_loaded());
1349 assert!(!TileCacheEntry::Pending.is_failed());
1350 assert!(TileCacheEntry::Pending.data().is_none());
1351
1352 let loaded = TileCacheEntry::Loaded(CachedTile {
1353 data: dummy_tile_data(),
1354 freshness: TileFreshness::default(),
1355 loaded_at: SystemTime::now(),
1356 });
1357 assert!(!loaded.is_pending());
1358 assert!(loaded.is_loaded());
1359 assert!(!loaded.is_failed());
1360 assert!(loaded.data().is_some());
1361
1362 let failed = TileCacheEntry::Failed {
1363 error: "err".into(),
1364 stale: None,
1365 };
1366 assert!(!failed.is_pending());
1367 assert!(!failed.is_loaded());
1368 assert!(failed.is_failed());
1369 assert!(failed.data().is_none());
1370 }
1371
1372 #[test]
1373 fn expired_tile_is_renderable_and_refreshable() {
1374 let mut cache = TileCache::new(4);
1375 let id = TileId::new(1, 0, 0);
1376 cache.promote(id, expiring_tile_response());
1377
1378 assert_eq!(cache.expired_ids(), vec![id]);
1379 assert!(cache.mark_expired(id));
1380 assert!(cache.get(&id).unwrap().is_expired());
1381 assert!(cache.get(&id).unwrap().is_renderable());
1382 assert!(cache.start_reload(id));
1383 assert!(cache.get(&id).unwrap().is_reloading());
1384 }
1385
1386 #[test]
1387 fn failed_reload_keeps_stale_payload_renderable() {
1388 let mut cache = TileCache::new(4);
1389 let id = TileId::new(1, 0, 0);
1390 cache.promote(id, dummy_tile_response());
1391 assert!(cache.start_reload(id));
1392
1393 cache.mark_failed(id, &TileError::Network("timeout".into()));
1394 let entry = cache.get(&id).unwrap();
1395 assert!(entry.is_expired());
1396 assert!(entry.is_renderable());
1397 }
1398
1399 #[test]
1402 fn lru_evicts_least_recently_used_first() {
1403 let mut cache = TileCache::new(3);
1404 let a = TileId::new(0, 0, 0);
1405 let b = TileId::new(1, 0, 0);
1406 let c = TileId::new(2, 0, 0);
1407 cache.insert_pending(a);
1408 cache.insert_pending(b);
1409 cache.insert_pending(c);
1410
1411 cache.touch(&a);
1413
1414 let d = TileId::new(3, 0, 0);
1415 cache.insert_pending(d);
1416
1417 assert!(!cache.contains(&b), "b should be evicted as LRU");
1419 assert!(cache.contains(&a));
1420 assert!(cache.contains(&c));
1421 assert!(cache.contains(&d));
1422 }
1423
1424 #[test]
1425 fn lru_touch_moves_to_mru_end() {
1426 let mut cache = TileCache::new(2);
1427 let a = TileId::new(0, 0, 0);
1428 let b = TileId::new(1, 0, 0);
1429 cache.insert_pending(a);
1430 cache.insert_pending(b);
1431
1432 cache.touch(&a);
1434
1435 let c = TileId::new(2, 0, 0);
1436 cache.insert_pending(c);
1437
1438 assert!(!cache.contains(&b), "b should be evicted");
1439 assert!(cache.contains(&a));
1440 assert!(cache.contains(&c));
1441 }
1442
1443 #[test]
1444 fn lru_remove_is_o1() {
1445 let mut cache = TileCache::new(5);
1446 for i in 0..5 {
1447 cache.insert_pending(TileId::new(i, 0, 0));
1448 }
1449
1450 let mid = TileId::new(2, 0, 0);
1452 assert!(cache.remove(&mid));
1453 assert!(!cache.contains(&mid));
1454 assert_eq!(cache.len(), 4);
1455
1456 cache.insert_pending(TileId::new(5, 0, 0));
1458 assert_eq!(cache.len(), 5);
1459 }
1460
1461 #[test]
1464 fn refresh_ttl_updates_freshness_without_replacing_data() {
1465 let mut cache = TileCache::new(4);
1466 let id = TileId::new(1, 0, 0);
1467 cache.insert_pending(id);
1468 cache.promote(id, expiring_tile_response());
1469
1470 assert!(cache.start_reload(id));
1472 assert!(cache.get(&id).unwrap().is_reloading());
1473
1474 let new_freshness = TileFreshness {
1476 expires_at: Some(SystemTime::now() + Duration::from_secs(3600)),
1477 etag: Some("etag-2".into()),
1478 last_modified: None,
1479 };
1480 assert!(cache.refresh_ttl(id, new_freshness.clone()));
1481
1482 let entry = cache.get(&id).unwrap();
1483 assert!(entry.is_loaded(), "should be back to Loaded state");
1484 assert!(!entry.is_expired());
1485
1486 let freshness = entry.freshness().unwrap();
1487 assert_eq!(freshness.etag.as_deref(), Some("etag-2"));
1488 }
1489
1490 #[test]
1491 fn refresh_ttl_returns_false_for_pending_entry() {
1492 let mut cache = TileCache::new(4);
1493 let id = TileId::new(1, 0, 0);
1494 cache.insert_pending(id);
1495
1496 let freshness = TileFreshness::default();
1497 assert!(!cache.refresh_ttl(id, freshness));
1498 }
1499
1500 #[test]
1503 fn revalidation_hint_extracts_etag_and_last_modified() {
1504 let mut cache = TileCache::new(4);
1505 let id = TileId::new(1, 0, 0);
1506 cache.insert_pending(id);
1507 cache.promote(id, expiring_tile_response());
1508
1509 let hint = cache.revalidation_hint(&id).expect("hint for loaded tile");
1510 assert_eq!(hint.etag.as_deref(), Some("etag-1"));
1511 assert!(hint.has_validators());
1512 }
1513
1514 #[test]
1515 fn revalidation_hint_returns_none_for_missing_tile() {
1516 let cache = TileCache::new(4);
1517 let id = TileId::new(1, 0, 0);
1518 assert!(cache.revalidation_hint(&id).is_none());
1519 }
1520
1521 #[test]
1524 fn byte_budget_evicts_when_exceeded() {
1525 let tile_bytes = 256 * 256 * 4;
1527 let mut cache = TileCache::with_byte_budget(10, tile_bytes * 2 + 100);
1529 assert_eq!(cache.max_bytes(), Some(tile_bytes * 2 + 100));
1530
1531 let a = TileId::new(0, 0, 0);
1532 let b = TileId::new(1, 0, 0);
1533 let c = TileId::new(2, 0, 0);
1534
1535 cache.insert_pending(a);
1536 cache.promote(a, dummy_tile_response());
1537 assert_eq!(cache.total_bytes(), tile_bytes);
1538
1539 cache.insert_pending(b);
1540 cache.promote(b, dummy_tile_response());
1541 assert_eq!(cache.total_bytes(), tile_bytes * 2);
1542
1543 cache.insert_pending(c);
1545 cache.promote(c, dummy_tile_response());
1546 assert!(!cache.contains(&a), "a should be evicted by byte budget");
1547 assert!(cache.contains(&b));
1548 assert!(cache.contains(&c));
1549 assert!(cache.total_bytes() <= tile_bytes * 2 + 100);
1550 }
1551
1552 #[test]
1553 fn byte_budget_tracks_removal() {
1554 let tile_bytes = 256 * 256 * 4;
1555 let mut cache = TileCache::with_byte_budget(10, tile_bytes * 10);
1556
1557 let id = TileId::new(0, 0, 0);
1558 cache.insert_pending(id);
1559 cache.promote(id, dummy_tile_response());
1560 assert_eq!(cache.total_bytes(), tile_bytes);
1561
1562 cache.remove(&id);
1563 assert_eq!(cache.total_bytes(), 0);
1564 }
1565}