use crate::tile_source::{TileData, TileError, TileFreshness, TileResponse};
use rustial_math::TileId;
use std::collections::HashMap;
use std::time::SystemTime;
const PENDING_ENTRY_CAP_DIVISOR: usize = 4;
const PENDING_ENTRY_CAP_MIN_CACHE_SIZE: usize = 64;
#[derive(Debug)]
struct LruLink {
prev: Option<TileId>,
next: Option<TileId>,
}
#[derive(Debug)]
pub struct EvictedTile {
pub id: TileId,
pub entry: TileCacheEntry,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TileCacheStats {
pub total_entries: usize,
pub pending_entries: usize,
pub loaded_entries: usize,
pub expired_entries: usize,
pub reloading_entries: usize,
pub failed_entries: usize,
pub renderable_entries: usize,
}
impl EvictedTile {
#[inline]
pub fn was_pending(&self) -> bool {
self.entry.is_pending()
}
}
#[derive(Debug, Default)]
pub struct InsertPendingResult {
pub inserted: bool,
pub evicted: Vec<EvictedTile>,
}
#[derive(Debug, Clone)]
pub struct CachedTile {
pub data: TileData,
pub freshness: TileFreshness,
pub loaded_at: SystemTime,
}
impl CachedTile {
#[inline]
pub fn is_expired_at(&self, now: SystemTime) -> bool {
self.freshness.is_expired_at(now)
}
#[inline]
pub fn is_expired(&self) -> bool {
self.freshness.is_expired()
}
}
#[derive(Debug)]
pub enum TileCacheEntry {
Pending,
Loaded(CachedTile),
Expired(CachedTile),
Reloading(CachedTile),
Failed {
error: String,
stale: Option<CachedTile>,
},
}
impl TileCacheEntry {
#[inline]
pub fn is_pending(&self) -> bool {
matches!(self, Self::Pending)
}
#[inline]
pub fn is_loaded(&self) -> bool {
matches!(
self,
Self::Loaded(_) | Self::Expired(_) | Self::Reloading(_)
)
}
#[inline]
pub fn is_expired(&self) -> bool {
matches!(self, Self::Expired(_))
}
#[inline]
pub fn is_reloading(&self) -> bool {
matches!(self, Self::Reloading(_))
}
#[inline]
pub fn is_failed(&self) -> bool {
matches!(self, Self::Failed { .. })
}
#[inline]
pub fn is_renderable(&self) -> bool {
self.data().is_some()
}
#[inline]
pub fn data(&self) -> Option<&TileData> {
self.cached_tile().map(|tile| &tile.data)
}
#[inline]
pub fn cached_tile(&self) -> Option<&CachedTile> {
match self {
Self::Loaded(tile) | Self::Expired(tile) | Self::Reloading(tile) => Some(tile),
Self::Failed { stale, .. } => stale.as_ref(),
Self::Pending => None,
}
}
#[inline]
pub fn freshness(&self) -> Option<&TileFreshness> {
self.cached_tile().map(|tile| &tile.freshness)
}
#[inline]
pub fn loaded_at(&self) -> Option<SystemTime> {
self.cached_tile().map(|tile| tile.loaded_at)
}
}
pub struct TileCache {
entries: HashMap<TileId, (TileCacheEntry, LruLink)>,
lru_head: Option<TileId>,
lru_tail: Option<TileId>,
max_entries: usize,
max_bytes: Option<usize>,
total_bytes: usize,
}
impl TileCache {
pub fn new(max_entries: usize) -> Self {
Self {
entries: HashMap::with_capacity(max_entries),
lru_head: None,
lru_tail: None,
max_entries,
max_bytes: None,
total_bytes: 0,
}
}
pub fn with_byte_budget(max_entries: usize, max_bytes: usize) -> Self {
Self {
entries: HashMap::with_capacity(max_entries),
lru_head: None,
lru_tail: None,
max_entries,
max_bytes: Some(max_bytes),
total_bytes: 0,
}
}
#[inline]
pub fn get(&self, id: &TileId) -> Option<&TileCacheEntry> {
self.entries.get(id).map(|(entry, _)| entry)
}
#[inline]
pub fn contains(&self, id: &TileId) -> bool {
self.entries.contains_key(id)
}
#[inline]
pub fn len(&self) -> usize {
self.entries.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[inline]
pub fn capacity(&self) -> usize {
self.max_entries
}
#[inline]
fn max_pending_entries(&self) -> usize {
if self.max_entries == 0 {
0
} else if self.max_entries < PENDING_ENTRY_CAP_MIN_CACHE_SIZE {
usize::MAX
} else {
(self.max_entries / PENDING_ENTRY_CAP_DIVISOR).max(1)
}
}
#[inline]
fn pending_entry_count(&self) -> usize {
self.entries
.values()
.filter(|(entry, _)| entry.is_pending())
.count()
}
#[inline]
pub fn total_bytes(&self) -> usize {
self.total_bytes
}
#[inline]
pub fn max_bytes(&self) -> Option<usize> {
self.max_bytes
}
pub fn pending_ids(&self) -> Vec<TileId> {
self.entries
.iter()
.filter_map(|(id, (entry, _))| entry.is_pending().then_some(*id))
.collect()
}
pub fn inflight_ids(&self) -> Vec<TileId> {
self.entries
.iter()
.filter_map(|(id, (entry, _))| match entry {
TileCacheEntry::Pending | TileCacheEntry::Reloading(_) => Some(*id),
_ => None,
})
.collect()
}
pub fn expired_ids_at(&self, now: SystemTime) -> Vec<TileId> {
self.entries
.iter()
.filter_map(|(id, (entry, _))| match entry {
TileCacheEntry::Loaded(tile) if tile.is_expired_at(now) => Some(*id),
TileCacheEntry::Expired(_) => Some(*id),
_ => None,
})
.collect()
}
pub fn expired_ids(&self) -> Vec<TileId> {
self.expired_ids_at(SystemTime::now())
}
pub fn stats(&self) -> TileCacheStats {
let mut stats = TileCacheStats {
total_entries: self.entries.len(),
..TileCacheStats::default()
};
for (entry, _) in self.entries.values() {
match entry {
TileCacheEntry::Pending => stats.pending_entries += 1,
TileCacheEntry::Loaded(_) => stats.loaded_entries += 1,
TileCacheEntry::Expired(_) => stats.expired_entries += 1,
TileCacheEntry::Reloading(_) => stats.reloading_entries += 1,
TileCacheEntry::Failed { .. } => stats.failed_entries += 1,
}
if entry.is_renderable() {
stats.renderable_entries += 1;
}
}
stats
}
fn lru_unlink(&mut self, id: &TileId) {
let Some((_, link)) = self.entries.get(id) else {
return;
};
let prev = link.prev;
let next = link.next;
if let Some(p) = prev {
if let Some((_, plink)) = self.entries.get_mut(&p) {
plink.next = next;
}
} else {
self.lru_head = next;
}
if let Some(n) = next {
if let Some((_, nlink)) = self.entries.get_mut(&n) {
nlink.prev = prev;
}
} else {
self.lru_tail = prev;
}
if let Some((_, link)) = self.entries.get_mut(id) {
link.prev = None;
link.next = None;
}
}
fn lru_push_back(&mut self, id: &TileId) {
if let Some(old_tail) = self.lru_tail {
if let Some((_, tlink)) = self.entries.get_mut(&old_tail) {
tlink.next = Some(*id);
}
if let Some((_, link)) = self.entries.get_mut(id) {
link.prev = Some(old_tail);
link.next = None;
}
self.lru_tail = Some(*id);
} else {
if let Some((_, link)) = self.entries.get_mut(id) {
link.prev = None;
link.next = None;
}
self.lru_head = Some(*id);
self.lru_tail = Some(*id);
}
}
pub fn touch(&mut self, id: &TileId) -> bool {
if !self.entries.contains_key(id) {
return false;
}
self.lru_unlink(id);
self.lru_push_back(id);
true
}
pub fn insert_pending_with_eviction(&mut self, id: TileId) -> InsertPendingResult {
if self.max_entries == 0 || self.entries.contains_key(&id) {
return InsertPendingResult::default();
}
if self.pending_entry_count() >= self.max_pending_entries() {
return InsertPendingResult::default();
}
let evicted = self.evict_if_full_for_pending_insert();
if self.entries.len() >= self.max_entries {
return InsertPendingResult::default();
}
self.entries.insert(
id,
(
TileCacheEntry::Pending,
LruLink {
prev: None,
next: None,
},
),
);
self.lru_push_back(&id);
InsertPendingResult {
inserted: true,
evicted,
}
}
pub fn insert_pending(&mut self, id: TileId) -> bool {
self.insert_pending_with_eviction(id).inserted
}
pub fn promote_with_eviction(
&mut self,
id: TileId,
response: TileResponse,
) -> Vec<EvictedTile> {
if self.max_entries == 0 {
return Vec::new();
}
let byte_len = response.data.byte_len();
let evicted = if !self.entries.contains_key(&id) {
let evicted = self.evict_if_full();
self.entries.insert(
id,
(
TileCacheEntry::Pending,
LruLink {
prev: None,
next: None,
},
),
);
self.lru_push_back(&id);
evicted
} else {
if let Some((old_entry, _)) = self.entries.get(&id) {
if let Some(data) = old_entry.data() {
self.total_bytes = self.total_bytes.saturating_sub(data.byte_len());
}
}
self.touch(&id);
Vec::new()
};
#[allow(clippy::unwrap_used)] {
self.entries.get_mut(&id).unwrap().0 = TileCacheEntry::Loaded(CachedTile {
data: response.data,
freshness: response.freshness,
loaded_at: SystemTime::now(),
});
}
self.total_bytes += byte_len;
let mut extra_evicted = Vec::new();
if let Some(max) = self.max_bytes {
while self.total_bytes > max && self.entries.len() > 1 {
if let Some(old) = self.lru_head {
if old == id {
break; }
if let Some(ev) = self.remove_and_return(old) {
extra_evicted.push(ev);
}
} else {
break;
}
}
}
if extra_evicted.is_empty() {
evicted
} else {
let mut all = evicted;
all.extend(extra_evicted);
all
}
}
pub fn promote(&mut self, id: TileId, response: TileResponse) {
let _ = self.promote_with_eviction(id, response);
}
pub fn mark_expired(&mut self, id: TileId) -> bool {
let Some((entry, _)) = self.entries.get_mut(&id) else {
return false;
};
match entry {
TileCacheEntry::Loaded(tile) => {
let tile = tile.clone();
*entry = TileCacheEntry::Expired(tile);
true
}
TileCacheEntry::Expired(_) | TileCacheEntry::Reloading(_) => true,
TileCacheEntry::Pending | TileCacheEntry::Failed { .. } => false,
}
}
pub fn start_reload(&mut self, id: TileId) -> bool {
let transitioned = match self.entries.get_mut(&id) {
Some((TileCacheEntry::Loaded(tile), _)) | Some((TileCacheEntry::Expired(tile), _)) => {
let tile = tile.clone();
self.entries.get_mut(&id).expect("entry should exist").0 =
TileCacheEntry::Reloading(tile);
true
}
_ => false,
};
if transitioned {
self.touch(&id);
}
transitioned
}
pub fn refresh_ttl(&mut self, id: TileId, freshness: TileFreshness) -> bool {
let Some((entry, _)) = self.entries.get_mut(&id) else {
return false;
};
match entry {
TileCacheEntry::Reloading(tile) | TileCacheEntry::Expired(tile) => {
tile.freshness = freshness;
let tile = tile.clone();
*entry = TileCacheEntry::Loaded(tile);
self.touch(&id);
true
}
TileCacheEntry::Loaded(tile) => {
tile.freshness = freshness;
true
}
TileCacheEntry::Pending | TileCacheEntry::Failed { .. } => false,
}
}
pub fn revalidation_hint(&self, id: &TileId) -> Option<crate::tile_source::RevalidationHint> {
let (entry, _) = self.entries.get(id)?;
let freshness = entry.freshness()?;
Some(crate::tile_source::RevalidationHint {
etag: freshness.etag.clone(),
last_modified: freshness.last_modified.clone(),
})
}
pub fn mark_failed(&mut self, id: TileId, error: &TileError) {
if let Some((entry, _)) = self.entries.get_mut(&id) {
match entry {
TileCacheEntry::Reloading(tile) | TileCacheEntry::Expired(tile) => {
let tile = tile.clone();
*entry = TileCacheEntry::Expired(tile);
}
TileCacheEntry::Loaded(_)
| TileCacheEntry::Pending
| TileCacheEntry::Failed { .. } => {
let stale = match entry {
TileCacheEntry::Loaded(tile) => Some(tile.clone()),
TileCacheEntry::Failed { stale, .. } => stale.clone(),
_ => None,
};
*entry = TileCacheEntry::Failed {
error: error.to_string(),
stale,
};
}
}
}
}
pub fn cancel_reload(&mut self, id: &TileId) -> bool {
let Some((entry, _)) = self.entries.get_mut(id) else {
return false;
};
match entry {
TileCacheEntry::Reloading(tile) => {
let tile = tile.clone();
*entry = TileCacheEntry::Expired(tile);
true
}
_ => false,
}
}
pub fn remove(&mut self, id: &TileId) -> bool {
self.remove_and_return(*id).is_some()
}
pub fn clear(&mut self) {
for (_, (entry, _)) in self.entries.drain() {
if let Some(data) = entry.data() {
self.total_bytes = self.total_bytes.saturating_sub(data.byte_len());
}
}
self.lru_head = None;
self.lru_tail = None;
self.total_bytes = 0;
}
fn remove_and_return(&mut self, id: TileId) -> Option<EvictedTile> {
self.lru_unlink(&id);
if let Some((entry, _)) = self.entries.remove(&id) {
if let Some(data) = entry.data() {
self.total_bytes = self.total_bytes.saturating_sub(data.byte_len());
}
Some(EvictedTile { id, entry })
} else {
None
}
}
fn evict_if_full(&mut self) -> Vec<EvictedTile> {
let mut evicted = Vec::new();
while self.entries.len() >= self.max_entries {
let victim = self
.find_failed_lru()
.or_else(|| self.find_non_pending_lru())
.or(self.lru_head);
if let Some(id) = victim {
if let Some(ev) = self.remove_and_return(id) {
evicted.push(ev);
}
} else {
break;
}
}
evicted
}
fn evict_if_full_for_pending_insert(&mut self) -> Vec<EvictedTile> {
let mut evicted = Vec::new();
while self.entries.len() >= self.max_entries {
let victim = self
.find_non_renderable_lru()
.or_else(|| self.find_pending_lru())
.or_else(|| self.find_non_renderable_non_pending_lru())
.or(self.lru_head);
if let Some(id) = victim {
if let Some(ev) = self.remove_and_return(id) {
evicted.push(ev);
}
} else {
break;
}
}
evicted
}
fn find_failed_lru(&self) -> Option<TileId> {
let mut cursor = self.lru_head;
while let Some(id) = cursor {
if let Some((entry, link)) = self.entries.get(&id) {
if entry.is_failed() {
return Some(id);
}
cursor = link.next;
} else {
break;
}
}
None
}
fn find_non_renderable_lru(&self) -> Option<TileId> {
let mut cursor = self.lru_head;
while let Some(id) = cursor {
if let Some((entry, link)) = self.entries.get(&id) {
if !entry.is_renderable() {
return Some(id);
}
cursor = link.next;
} else {
break;
}
}
None
}
fn find_non_renderable_non_pending_lru(&self) -> Option<TileId> {
let mut cursor = self.lru_head;
while let Some(id) = cursor {
if let Some((entry, link)) = self.entries.get(&id) {
if !entry.is_pending() && !entry.is_renderable() {
return Some(id);
}
cursor = link.next;
} else {
break;
}
}
None
}
fn find_pending_lru(&self) -> Option<TileId> {
let mut cursor = self.lru_head;
while let Some(id) = cursor {
if let Some((entry, link)) = self.entries.get(&id) {
if entry.is_pending() {
return Some(id);
}
cursor = link.next;
} else {
break;
}
}
None
}
fn find_non_pending_lru(&self) -> Option<TileId> {
let mut cursor = self.lru_head;
while let Some(id) = cursor {
if let Some((entry, link)) = self.entries.get(&id) {
if !entry.is_pending() {
return Some(id);
}
cursor = link.next;
} else {
break;
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tile_source::{DecodedImage, TileResponse};
use std::time::Duration;
fn dummy_tile_data() -> TileData {
TileData::Raster(DecodedImage {
width: 256,
height: 256,
data: vec![0u8; 256 * 256 * 4].into(),
})
}
fn dummy_tile_response() -> TileResponse {
TileResponse::from_data(dummy_tile_data())
}
fn expiring_tile_response() -> TileResponse {
TileResponse::from_data(dummy_tile_data()).with_freshness(TileFreshness {
expires_at: Some(SystemTime::now() - Duration::from_secs(1)),
etag: Some("etag-1".into()),
last_modified: None,
})
}
#[test]
fn new_cache_is_empty() {
let cache = TileCache::new(10);
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
assert_eq!(cache.capacity(), 10);
}
#[test]
fn zero_capacity_cache() {
let mut cache = TileCache::new(0);
let result = cache.insert_pending_with_eviction(TileId::new(0, 0, 0));
assert!(!result.inserted);
assert!(result.evicted.is_empty());
assert!(cache.is_empty());
}
#[test]
fn insert_and_get() {
let mut cache = TileCache::new(10);
let id = TileId::new(0, 0, 0);
assert!(cache.insert_pending(id));
assert!(cache.contains(&id));
assert!(cache.get(&id).unwrap().is_pending());
}
#[test]
fn no_double_insert() {
let mut cache = TileCache::new(10);
let id = TileId::new(0, 0, 0);
assert!(cache.insert_pending(id));
assert!(!cache.insert_pending(id));
assert_eq!(cache.len(), 1);
}
#[test]
fn insert_does_not_overwrite_loaded() {
let mut cache = TileCache::new(10);
let id = TileId::new(0, 0, 0);
cache.insert_pending(id);
cache.promote(id, dummy_tile_response());
assert!(!cache.insert_pending(id));
assert!(cache.get(&id).unwrap().is_loaded());
}
#[test]
fn promote_to_loaded() {
let mut cache = TileCache::new(10);
let id = TileId::new(0, 0, 0);
cache.insert_pending(id);
cache.promote(id, dummy_tile_response());
let entry = cache.get(&id).unwrap();
assert!(entry.is_loaded());
assert!(entry.data().is_some());
}
#[test]
fn promote_without_prior_pending() {
let mut cache = TileCache::new(10);
let id = TileId::new(5, 10, 10);
cache.promote(id, dummy_tile_response());
assert!(cache.contains(&id));
assert!(cache.get(&id).unwrap().is_loaded());
}
#[test]
fn pending_entry_cap_rejects_excess_pending_inserts() {
let mut cache = TileCache::new(64);
let ids: Vec<TileId> = (0..17).map(|i| TileId::new(5, i, 0)).collect();
assert_eq!(cache.max_pending_entries(), 16);
for &id in &ids[..16] {
assert!(cache.insert_pending(id));
}
assert!(!cache.insert_pending(ids[16]));
assert_eq!(cache.pending_entry_count(), 16);
assert_eq!(cache.len(), 16);
assert!(!cache.contains(&ids[16]));
}
#[test]
fn pending_entry_cap_preserves_loaded_entries() {
let mut cache = TileCache::new(64);
let loaded: Vec<TileId> = (0..6).map(|i| TileId::new(5, i, 0)).collect();
let pending: Vec<TileId> = (10..27).map(|i| TileId::new(5, i, 0)).collect();
for &id in &loaded {
cache.promote(id, dummy_tile_response());
}
for &id in &pending[..16] {
assert!(cache.insert_pending(id));
}
assert!(!cache.insert_pending(pending[16]));
assert_eq!(cache.pending_entry_count(), 16);
for &id in &loaded {
assert!(
cache.contains(&id),
"loaded tile should be retained when pending cap is hit"
);
}
}
#[test]
fn late_promotion_bypasses_pending_entry_cap() {
let mut cache = TileCache::new(64);
let late = TileId::new(6, 42, 0);
for i in 0..cache.max_pending_entries() {
assert!(cache.insert_pending(TileId::new(6, i as u32, 0)));
}
assert!(!cache.insert_pending(TileId::new(6, 63, 0)));
cache.promote(late, dummy_tile_response());
assert!(cache.contains(&late));
assert!(cache.get(&late).unwrap().is_loaded());
}
#[test]
fn pending_insert_prefers_evicting_pending_before_loaded() {
let mut cache = TileCache::new(3);
let a = TileId::new(1, 0, 0);
let b = TileId::new(1, 0, 1);
let c = TileId::new(1, 1, 0);
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);
cache.insert_pending(d);
assert!(
cache.contains(&a),
"'a' should survive (Loaded, still renderable)"
);
assert!(cache.contains(&b), "'b' should survive (MRU Loaded)");
assert!(
!cache.contains(&c),
"'c' should be evicted (oldest Pending)"
);
assert!(cache.contains(&d));
}
#[test]
fn pending_insert_keeps_expired_renderable_tile_over_pending() {
let mut cache = TileCache::new(2);
let expired = TileId::new(4, 0, 0);
let pending = TileId::new(4, 1, 0);
let incoming = TileId::new(4, 2, 0);
cache.promote(expired, expiring_tile_response());
assert!(cache.mark_expired(expired));
assert!(cache.insert_pending(pending));
let result = cache.insert_pending_with_eviction(incoming);
assert!(result.inserted);
assert!(
cache.contains(&expired),
"expired renderable tile should be retained"
);
assert!(
!cache.contains(&pending),
"older pending tile should be evicted first"
);
assert!(cache.contains(&incoming));
}
#[test]
fn pending_insert_evicts_lru_renderable_as_last_resort() {
let mut cache = TileCache::new(2);
let loaded = TileId::new(5, 0, 0);
let expired = TileId::new(5, 1, 0);
let incoming = TileId::new(5, 2, 0);
cache.promote(loaded, dummy_tile_response());
cache.promote(expired, expiring_tile_response());
assert!(cache.mark_expired(expired));
let result = cache.insert_pending_with_eviction(incoming);
assert!(
result.inserted,
"pending insert must succeed by evicting LRU renderable"
);
assert_eq!(result.evicted.len(), 1);
assert_eq!(
result.evicted[0].id, loaded,
"LRU renderable entry should be evicted"
);
assert!(cache.contains(&expired));
assert!(cache.contains(&incoming));
}
#[test]
fn pending_insert_evicts_lru_renderable_failed_entry_as_last_resort() {
let mut cache = TileCache::new(2);
let failed_stale = TileId::new(6, 0, 0);
let loaded = TileId::new(6, 1, 0);
let incoming = TileId::new(6, 2, 0);
cache.promote(failed_stale, dummy_tile_response());
cache.mark_failed(failed_stale, &TileError::Network("boom".into()));
cache.promote(loaded, dummy_tile_response());
let result = cache.insert_pending_with_eviction(incoming);
assert!(
result.inserted,
"pending insert must succeed by evicting LRU renderable"
);
assert_eq!(result.evicted.len(), 1);
assert_eq!(
result.evicted[0].id, failed_stale,
"LRU failed-but-renderable entry should be evicted"
);
assert!(cache.contains(&loaded));
assert!(cache.contains(&incoming));
}
#[test]
fn cancel_reload_demotes_reloading_to_expired() {
let mut cache = TileCache::new(10);
let id = TileId::new(3, 0, 0);
cache.promote(id, expiring_tile_response());
assert!(cache.start_reload(id));
assert!(cache.get(&id).unwrap().is_reloading());
assert!(cache.cancel_reload(&id));
let entry = cache.get(&id).unwrap();
assert!(entry.is_expired());
assert!(entry.is_renderable());
assert!(entry.data().is_some());
}
#[test]
fn cancel_reload_has_no_effect_on_non_reloading() {
let mut cache = TileCache::new(10);
let pending = TileId::new(3, 0, 0);
let loaded = TileId::new(3, 1, 0);
cache.insert_pending(pending);
cache.promote(loaded, dummy_tile_response());
assert!(!cache.cancel_reload(&pending));
assert!(!cache.cancel_reload(&loaded));
assert!(cache.get(&pending).unwrap().is_pending());
assert!(cache.get(&loaded).unwrap().is_loaded());
}
#[test]
fn mark_failed_sets_state() {
let mut cache = TileCache::new(10);
let id = TileId::new(0, 0, 0);
cache.insert_pending(id);
let err = TileError::Network("timeout".into());
cache.mark_failed(id, &err);
let entry = cache.get(&id).unwrap();
assert!(entry.is_failed());
if let TileCacheEntry::Failed { error, .. } = entry {
assert!(error.contains("timeout"));
}
}
#[test]
fn mark_failed_ignores_missing_entry() {
let mut cache = TileCache::new(1);
cache.mark_failed(TileId::new(1, 0, 0), &TileError::Other("boom".into()));
assert!(cache.is_empty());
}
#[test]
fn remove_existing() {
let mut cache = TileCache::new(10);
let id = TileId::new(0, 0, 0);
cache.insert_pending(id);
assert!(cache.remove(&id));
assert!(!cache.contains(&id));
assert!(cache.is_empty());
}
#[test]
fn remove_nonexistent() {
let mut cache = TileCache::new(10);
assert!(!cache.remove(&TileId::new(0, 0, 0)));
}
#[test]
fn clear_empties_cache() {
let mut cache = TileCache::new(10);
cache.insert_pending(TileId::new(0, 0, 0));
cache.insert_pending(TileId::new(1, 0, 0));
cache.promote(TileId::new(1, 0, 0), dummy_tile_response());
assert_eq!(cache.len(), 2);
cache.clear();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
assert_eq!(cache.capacity(), 10);
}
#[test]
fn eviction_at_capacity() {
let mut cache = TileCache::new(2);
let a = TileId::new(1, 0, 0);
let b = TileId::new(1, 0, 1);
let c = TileId::new(1, 1, 0);
cache.insert_pending(a);
cache.insert_pending(b);
cache.insert_pending(c);
assert_eq!(cache.len(), 2);
assert!(!cache.contains(&a), "first entry should be evicted");
assert!(cache.contains(&b));
assert!(cache.contains(&c));
}
#[test]
fn eviction_fifo_without_touch() {
let mut cache = TileCache::new(3);
let ids: Vec<TileId> = (0..5).map(|i| TileId::new(4, i, 0)).collect();
for &id in &ids {
cache.insert_pending(id);
}
assert_eq!(cache.len(), 3);
assert!(!cache.contains(&ids[0]));
assert!(!cache.contains(&ids[1]));
assert!(cache.contains(&ids[2]));
assert!(cache.contains(&ids[3]));
assert!(cache.contains(&ids[4]));
}
#[test]
fn entry_state_helpers() {
assert!(TileCacheEntry::Pending.is_pending());
assert!(!TileCacheEntry::Pending.is_loaded());
assert!(!TileCacheEntry::Pending.is_failed());
assert!(TileCacheEntry::Pending.data().is_none());
let loaded = TileCacheEntry::Loaded(CachedTile {
data: dummy_tile_data(),
freshness: TileFreshness::default(),
loaded_at: SystemTime::now(),
});
assert!(!loaded.is_pending());
assert!(loaded.is_loaded());
assert!(!loaded.is_failed());
assert!(loaded.data().is_some());
let failed = TileCacheEntry::Failed {
error: "err".into(),
stale: None,
};
assert!(!failed.is_pending());
assert!(!failed.is_loaded());
assert!(failed.is_failed());
assert!(failed.data().is_none());
}
#[test]
fn expired_tile_is_renderable_and_refreshable() {
let mut cache = TileCache::new(4);
let id = TileId::new(1, 0, 0);
cache.promote(id, expiring_tile_response());
assert_eq!(cache.expired_ids(), vec![id]);
assert!(cache.mark_expired(id));
assert!(cache.get(&id).unwrap().is_expired());
assert!(cache.get(&id).unwrap().is_renderable());
assert!(cache.start_reload(id));
assert!(cache.get(&id).unwrap().is_reloading());
}
#[test]
fn failed_reload_keeps_stale_payload_renderable() {
let mut cache = TileCache::new(4);
let id = TileId::new(1, 0, 0);
cache.promote(id, dummy_tile_response());
assert!(cache.start_reload(id));
cache.mark_failed(id, &TileError::Network("timeout".into()));
let entry = cache.get(&id).unwrap();
assert!(entry.is_expired());
assert!(entry.is_renderable());
}
#[test]
fn lru_evicts_least_recently_used_first() {
let mut cache = TileCache::new(3);
let a = TileId::new(0, 0, 0);
let b = TileId::new(1, 0, 0);
let c = TileId::new(2, 0, 0);
cache.insert_pending(a);
cache.insert_pending(b);
cache.insert_pending(c);
cache.touch(&a);
let d = TileId::new(3, 0, 0);
cache.insert_pending(d);
assert!(!cache.contains(&b), "b should be evicted as LRU");
assert!(cache.contains(&a));
assert!(cache.contains(&c));
assert!(cache.contains(&d));
}
#[test]
fn lru_touch_moves_to_mru_end() {
let mut cache = TileCache::new(2);
let a = TileId::new(0, 0, 0);
let b = TileId::new(1, 0, 0);
cache.insert_pending(a);
cache.insert_pending(b);
cache.touch(&a);
let c = TileId::new(2, 0, 0);
cache.insert_pending(c);
assert!(!cache.contains(&b), "b should be evicted");
assert!(cache.contains(&a));
assert!(cache.contains(&c));
}
#[test]
fn lru_remove_is_o1() {
let mut cache = TileCache::new(5);
for i in 0..5 {
cache.insert_pending(TileId::new(i, 0, 0));
}
let mid = TileId::new(2, 0, 0);
assert!(cache.remove(&mid));
assert!(!cache.contains(&mid));
assert_eq!(cache.len(), 4);
cache.insert_pending(TileId::new(5, 0, 0));
assert_eq!(cache.len(), 5);
}
#[test]
fn refresh_ttl_updates_freshness_without_replacing_data() {
let mut cache = TileCache::new(4);
let id = TileId::new(1, 0, 0);
cache.insert_pending(id);
cache.promote(id, expiring_tile_response());
assert!(cache.start_reload(id));
assert!(cache.get(&id).unwrap().is_reloading());
let new_freshness = TileFreshness {
expires_at: Some(SystemTime::now() + Duration::from_secs(3600)),
etag: Some("etag-2".into()),
last_modified: None,
};
assert!(cache.refresh_ttl(id, new_freshness.clone()));
let entry = cache.get(&id).unwrap();
assert!(entry.is_loaded(), "should be back to Loaded state");
assert!(!entry.is_expired());
let freshness = entry.freshness().unwrap();
assert_eq!(freshness.etag.as_deref(), Some("etag-2"));
}
#[test]
fn refresh_ttl_returns_false_for_pending_entry() {
let mut cache = TileCache::new(4);
let id = TileId::new(1, 0, 0);
cache.insert_pending(id);
let freshness = TileFreshness::default();
assert!(!cache.refresh_ttl(id, freshness));
}
#[test]
fn revalidation_hint_extracts_etag_and_last_modified() {
let mut cache = TileCache::new(4);
let id = TileId::new(1, 0, 0);
cache.insert_pending(id);
cache.promote(id, expiring_tile_response());
let hint = cache.revalidation_hint(&id).expect("hint for loaded tile");
assert_eq!(hint.etag.as_deref(), Some("etag-1"));
assert!(hint.has_validators());
}
#[test]
fn revalidation_hint_returns_none_for_missing_tile() {
let cache = TileCache::new(4);
let id = TileId::new(1, 0, 0);
assert!(cache.revalidation_hint(&id).is_none());
}
#[test]
fn byte_budget_evicts_when_exceeded() {
let tile_bytes = 256 * 256 * 4;
let mut cache = TileCache::with_byte_budget(10, tile_bytes * 2 + 100);
assert_eq!(cache.max_bytes(), Some(tile_bytes * 2 + 100));
let a = TileId::new(0, 0, 0);
let b = TileId::new(1, 0, 0);
let c = TileId::new(2, 0, 0);
cache.insert_pending(a);
cache.promote(a, dummy_tile_response());
assert_eq!(cache.total_bytes(), tile_bytes);
cache.insert_pending(b);
cache.promote(b, dummy_tile_response());
assert_eq!(cache.total_bytes(), tile_bytes * 2);
cache.insert_pending(c);
cache.promote(c, dummy_tile_response());
assert!(!cache.contains(&a), "a should be evicted by byte budget");
assert!(cache.contains(&b));
assert!(cache.contains(&c));
assert!(cache.total_bytes() <= tile_bytes * 2 + 100);
}
#[test]
fn byte_budget_tracks_removal() {
let tile_bytes = 256 * 256 * 4;
let mut cache = TileCache::with_byte_budget(10, tile_bytes * 10);
let id = TileId::new(0, 0, 0);
cache.insert_pending(id);
cache.promote(id, dummy_tile_response());
assert_eq!(cache.total_bytes(), tile_bytes);
cache.remove(&id);
assert_eq!(cache.total_bytes(), 0);
}
}