Skip to main content

rustial_engine/
tile_cache.rs

1//! # LRU tile cache
2//!
3//! A fixed-capacity, least-recently-used (LRU) in-memory cache for map
4//! tiles.  This is the primary tile storage consumed by
5//! [`TileManager`](crate::tile_manager::TileManager) during the per-frame
6//! update loop.
7//!
8//! ## Lifecycle of a cached tile
9//!
10//! ```text
11//!  insert_pending(id)       promote(id, data)        evict (LRU)
12//!        |                        |                        |
13//!        v                        v                        v
14//!    +----------+            +----------+            +---------+
15//!    | Pending  | ---------> | Loaded   | ---------> | removed |
16//!    +----------+            +----------+            +---------+
17//!        |
18//!        | mark_failed(id, err)
19//!        v
20//!    +----------+
21//!    | Failed   | ---------> | removed |
22//!    +----------+            +---------+
23//! ```
24//!
25//! 1. **Pending** -- a fetch has been issued by the tile source but the
26//!    response has not arrived yet.
27//! 2. **Loaded** -- the tile data (raster image) is available for
28//!    rendering.
29//! 3. **Failed** -- the fetch failed (network error, 404, decode error).
30//!    Failed entries remain in the cache so the manager does not
31//!    immediately re-request them; they will be evicted naturally by
32//!    the LRU policy.
33//!
34//! ## Eviction policy
35//!
36//! When the cache is full and a new entry needs to be inserted, the
37//! **least-recently-used** entry is removed -- regardless of its state
38//! (Pending, Loaded, or Failed).  This means an in-flight fetch may be
39//! evicted if the cache is small relative to the visible tile count;
40//! the tile source will simply re-request it on the next frame.
41//!
42//! "Recently used" is updated by:
43//! - [`insert_pending`](TileCache::insert_pending) -- appends to the
44//!   back of the LRU queue.
45//! - [`promote`](TileCache::promote) -- moves the entry to the back
46//!   (most-recently-used position).
47//!
48//! [`mark_failed`](TileCache::mark_failed) does **not** touch the LRU
49//! order, so failed tiles naturally drift toward eviction.
50//!
51//! ## Complexity
52//!
53//! | Operation | Time | Notes |
54//! |-----------|------|-------|
55//! | `get` | O(1) | HashMap lookup |
56//! | `insert_pending` | O(1) amortised | HashMap insert + linked-list append |
57//! | `promote` | O(1) amortised | HashMap overwrite + linked-list move-to-tail |
58//! | `mark_failed` | O(1) | HashMap overwrite only (no LRU reorder) |
59//! | `remove` | O(1) | HashMap remove + linked-list unlink |
60//! | `touch` | O(1) | Linked-list unlink + append |
61//!
62//! All LRU operations are O(1) via an intrusive doubly-linked list
63//! threaded through `Option<TileId>` prev/next pointers stored
64//! alongside each cache entry.
65//!
66//! ## Thread safety
67//!
68//! `TileCache` is `Send + Sync` (all fields are owned, no interior
69//! mutability).  The engine wraps it inside `TileManager` which is
70//! itself owned by `MapState`, guarded by an `RwLock` in the facade
71//! crate's `MapHandle`.
72// ---------------------------------------------------------------------------
73
74use crate::tile_source::{TileData, TileError, TileFreshness, TileResponse};
75use rustial_math::TileId;
76use std::collections::HashMap;
77use std::time::SystemTime;
78
79/// Fraction of total cache entries that may be occupied by pure `Pending`
80/// requests before new pending inserts are rejected.
81///
82/// This preserves room for renderable loaded tiles during long `fly_to`
83/// transitions where many transient requests can otherwise crowd out the
84/// imagery already available for fallback rendering.
85const PENDING_ENTRY_CAP_DIVISOR: usize = 4;
86
87/// Minimum cache size at which the pending-entry cap is enabled.
88///
89/// Small caches used in unit tests or constrained scenarios keep the
90/// original behaviour so the cap only activates for production-sized
91/// caches where large fly-to backlogs can otherwise crowd out loaded tiles.
92const PENDING_ENTRY_CAP_MIN_CACHE_SIZE: usize = 64;
93
94// ---------------------------------------------------------------------------
95// O(1) LRU ordering via intrusive doubly-linked list
96// ---------------------------------------------------------------------------
97
98/// Intrusive doubly-linked list node embedded alongside each cache entry.
99///
100/// `prev` and `next` are `Option<TileId>` pointers into the same
101/// `HashMap`.  The list has a virtual head (`lru_head`) and tail
102/// (`lru_tail`) stored on `TileCache` itself.
103#[derive(Debug)]
104struct LruLink {
105    prev: Option<TileId>,
106    next: Option<TileId>,
107}
108
109// ---------------------------------------------------------------------------
110// Eviction reporting
111// ---------------------------------------------------------------------------
112
113/// A tile evicted from the cache during an insertion or promotion.
114#[derive(Debug)]
115pub struct EvictedTile {
116    /// ID of the evicted tile.
117    pub id: TileId,
118    /// State the tile had at the moment of eviction.
119    pub entry: TileCacheEntry,
120}
121
122/// Snapshot counts for the current cache state.
123#[derive(Debug, Clone, Default, PartialEq, Eq)]
124pub struct TileCacheStats {
125    /// Total entries currently retained in the cache.
126    pub total_entries: usize,
127    /// Entries in `Pending` state.
128    pub pending_entries: usize,
129    /// Entries in `Loaded` state.
130    pub loaded_entries: usize,
131    /// Entries in `Expired` state.
132    pub expired_entries: usize,
133    /// Entries in `Reloading` state.
134    pub reloading_entries: usize,
135    /// Entries in `Failed` state.
136    pub failed_entries: usize,
137    /// Entries that still have renderable payloads.
138    pub renderable_entries: usize,
139}
140
141impl EvictedTile {
142    /// Returns `true` if the evicted tile was still in flight.
143    #[inline]
144    pub fn was_pending(&self) -> bool {
145        self.entry.is_pending()
146    }
147}
148
149/// Result of inserting a pending tile.
150#[derive(Debug, Default)]
151pub struct InsertPendingResult {
152    /// Whether the tile was newly inserted.
153    pub inserted: bool,
154    /// Tiles evicted to make room for this insertion.
155    pub evicted: Vec<EvictedTile>,
156}
157
158/// Loaded tile payload retained in the cache, including freshness metadata.
159#[derive(Debug, Clone)]
160pub struct CachedTile {
161    /// Decoded tile data.
162    pub data: TileData,
163    /// Freshness metadata from the source response.
164    pub freshness: TileFreshness,
165    /// Wall-clock time when this tile was promoted to the `Loaded` state.
166    ///
167    /// Used by the tile-fade system to compute the elapsed time since the
168    /// tile first became renderable.  Set once during
169    /// [`TileCache::promote_with_eviction`] and never mutated afterwards.
170    pub loaded_at: SystemTime,
171}
172
173impl CachedTile {
174    /// Return `true` when the tile is stale at `now`.
175    #[inline]
176    pub fn is_expired_at(&self, now: SystemTime) -> bool {
177        self.freshness.is_expired_at(now)
178    }
179
180    /// Return `true` when the tile is stale right now.
181    #[inline]
182    pub fn is_expired(&self) -> bool {
183        self.freshness.is_expired()
184    }
185}
186
187/// State of a tile in the cache.
188#[derive(Debug)]
189pub enum TileCacheEntry {
190    /// A fetch has been issued but no renderable payload exists yet.
191    Pending,
192    /// The tile is loaded and considered fresh.
193    Loaded(CachedTile),
194    /// The tile payload is stale but still renderable while refresh is pending.
195    Expired(CachedTile),
196    /// A refresh is in flight; the previous payload remains renderable.
197    Reloading(CachedTile),
198    /// The tile fetch failed. The optional cached tile remains renderable when
199    /// the failure happened during refresh/revalidation.
200    Failed {
201        /// Human-readable error description.
202        error: String,
203        /// Previously retained renderable payload, if any.
204        stale: Option<CachedTile>,
205    },
206}
207
208impl TileCacheEntry {
209    /// Returns `true` if the entry is currently pending load.
210    #[inline]
211    pub fn is_pending(&self) -> bool {
212        matches!(self, Self::Pending)
213    }
214
215    /// Returns `true` if the entry currently has a loaded or reloadable payload.
216    #[inline]
217    pub fn is_loaded(&self) -> bool {
218        matches!(self, Self::Loaded(_) | Self::Expired(_) | Self::Reloading(_))
219    }
220
221    /// Returns `true` if the entry is in a stale-but-renderable expired state.
222    #[inline]
223    pub fn is_expired(&self) -> bool {
224        matches!(self, Self::Expired(_))
225    }
226
227    /// Returns `true` if the entry is actively reloading while keeping old data.
228    #[inline]
229    pub fn is_reloading(&self) -> bool {
230        matches!(self, Self::Reloading(_))
231    }
232
233    /// Returns `true` if the entry is in a failed state.
234    #[inline]
235    pub fn is_failed(&self) -> bool {
236        matches!(self, Self::Failed { .. })
237    }
238
239    /// Returns `true` if this entry still has renderable tile data.
240    #[inline]
241    pub fn is_renderable(&self) -> bool {
242        self.data().is_some()
243    }
244
245    /// Return the renderable tile payload, if any.
246    #[inline]
247    pub fn data(&self) -> Option<&TileData> {
248        self.cached_tile().map(|tile| &tile.data)
249    }
250
251    /// Return the loaded payload, if any.
252    #[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    /// Return freshness metadata for renderable entries.
262    #[inline]
263    pub fn freshness(&self) -> Option<&TileFreshness> {
264        self.cached_tile().map(|tile| &tile.freshness)
265    }
266
267    /// Return the wall-clock time when this entry was promoted to Loaded.
268    ///
269    /// Returns `None` for entries that have no renderable payload.
270    #[inline]
271    pub fn loaded_at(&self) -> Option<SystemTime> {
272        self.cached_tile().map(|tile| tile.loaded_at)
273    }
274}
275
276// ---------------------------------------------------------------------------
277// TileCache
278// ---------------------------------------------------------------------------
279
280/// A fixed-capacity LRU in-memory tile cache.
281///
282/// See the [module-level documentation](self) for eviction policy,
283/// lifecycle, and complexity details.
284pub struct TileCache {
285    /// Primary storage: tile ID -> (entry, LRU link).
286    entries: HashMap<TileId, (TileCacheEntry, LruLink)>,
287    /// Head of the LRU doubly-linked list (least recently used).
288    lru_head: Option<TileId>,
289    /// Tail of the LRU doubly-linked list (most recently used).
290    lru_tail: Option<TileId>,
291    /// Maximum number of entries before eviction kicks in.
292    max_entries: usize,
293    /// Optional byte budget.  When set, eviction also triggers when
294    /// total payload bytes exceed this limit.
295    max_bytes: Option<usize>,
296    /// Running total of payload bytes in the cache.
297    total_bytes: usize,
298}
299
300impl TileCache {
301    /// Create a new cache with the given maximum capacity.
302    ///
303    /// A `max_entries` of 0 is valid but degenerate -- every insertion
304    /// is immediately evicted.  Typical values range from 100 (low
305    /// memory) to 1000+ (large-screen / high-zoom).
306    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    /// Create a cache with both an entry-count and a byte-budget limit.
318    ///
319    /// Eviction triggers when **either** limit is exceeded.
320    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    // -- Queries ----------------------------------------------------------
332
333    /// Look up a tile in the cache.
334    ///
335    /// Returns `None` if the tile has never been inserted or has been
336    /// evicted.  Does **not** update the LRU order (read-only).
337    #[inline]
338    pub fn get(&self, id: &TileId) -> Option<&TileCacheEntry> {
339        self.entries.get(id).map(|(entry, _)| entry)
340    }
341
342    /// Returns `true` if the tile is present (in any state).
343    #[inline]
344    pub fn contains(&self, id: &TileId) -> bool {
345        self.entries.contains_key(id)
346    }
347
348    /// Number of entries currently in the cache.
349    #[inline]
350    pub fn len(&self) -> usize {
351        self.entries.len()
352    }
353
354    /// Whether the cache is empty.
355    #[inline]
356    pub fn is_empty(&self) -> bool {
357        self.entries.is_empty()
358    }
359
360    /// Maximum capacity configured at construction time.
361    #[inline]
362    pub fn capacity(&self) -> usize {
363        self.max_entries
364    }
365
366    /// Maximum number of pure `Pending` entries permitted at once.
367    #[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    /// Number of pure `Pending` entries currently retained.
379    #[inline]
380    fn pending_entry_count(&self) -> usize {
381        self.entries
382            .values()
383            .filter(|(entry, _)| entry.is_pending())
384            .count()
385    }
386
387    /// Total payload bytes currently tracked in the cache.
388    #[inline]
389    pub fn total_bytes(&self) -> usize {
390        self.total_bytes
391    }
392
393    /// The byte budget, if configured.
394    #[inline]
395    pub fn max_bytes(&self) -> Option<usize> {
396        self.max_bytes
397    }
398
399    /// Return the IDs of all pending tiles currently in the cache.
400    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    /// Return the IDs of all tiles that are actively pending or reloading.
408    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    /// Return the IDs of all tiles that are stale and eligible for refresh at `now`.
419    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    /// Return the IDs of all tiles that are stale and eligible for refresh now.
431    pub fn expired_ids(&self) -> Vec<TileId> {
432        self.expired_ids_at(SystemTime::now())
433    }
434
435    /// Return a state-count snapshot of the cache contents.
436    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    // -- LRU linked-list helpers (O(1)) -----------------------------------
459
460    /// Unlink a node from the doubly-linked list without removing it from
461    /// the `entries` HashMap.
462    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        // Clear own pointers.
484        if let Some((_, link)) = self.entries.get_mut(id) {
485            link.prev = None;
486            link.next = None;
487        }
488    }
489
490    /// Append a node (already in `entries`) to the MRU tail of the list.
491    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            // Empty list.
503            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    // -- Mutations --------------------------------------------------------
513
514    /// Mark an existing entry as recently used (O(1)).
515    ///
516    /// Returns `true` if the tile was present and moved to the MRU end.
517    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    /// Insert a [`Pending`](TileCacheEntry::Pending) entry for `id` and report
527    /// any evictions that occurred.
528    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    /// Insert a [`Pending`](TileCacheEntry::Pending) entry for `id`.
551    ///
552    /// Returns `true` if the entry was newly inserted.
553    pub fn insert_pending(&mut self, id: TileId) -> bool {
554        self.insert_pending_with_eviction(id).inserted
555    }
556
557    /// Promote an entry to [`Loaded`](TileCacheEntry::Loaded) and move
558    /// it to the most-recently-used position, reporting any evicted tiles.
559    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            // Remove old byte count before replacing.
573            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        // Byte-budget eviction: continue evicting LRU entries until we
590        // are within budget.
591        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; // Don't evict the entry we just promoted.
597                    }
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    /// Promote an entry to [`Loaded`](TileCacheEntry::Loaded) and move
617    /// it to the most-recently-used position.
618    pub fn promote(&mut self, id: TileId, response: TileResponse) {
619        let _ = self.promote_with_eviction(id, response);
620    }
621
622    /// Mark a loaded tile as expired while keeping it renderable.
623    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    /// Transition a stale tile into reloading while retaining its old payload.
640    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    /// Refresh the TTL of a reloading/expired entry without replacing its
658    /// payload.
659    ///
660    /// This is the cache-side handler for `304 Not Modified` responses.
661    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    /// Return the revalidation hint (etag + last-modified) for a tile.
683    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    /// Mark the tile as failed, preserving stale renderable payload when possible.
693    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    /// Cancel an in-flight reload, demoting the entry back to `Expired`
716    /// while preserving its renderable payload.
717    ///
718    /// Returns `true` if the entry was `Reloading` and was demoted.
719    /// Has no effect on entries in any other state.
720    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    /// Remove a tile from the cache (O(1)).
735    ///
736    /// Returns `true` if the tile was present and removed.
737    pub fn remove(&mut self, id: &TileId) -> bool {
738        self.remove_and_return(*id).is_some()
739    }
740
741    /// Remove all entries from the cache.
742    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    // -- Internal ---------------------------------------------------------
754
755    /// Remove a tile and return its eviction record.
756    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    /// Evict entries until the cache is below capacity.
769    ///
770    /// Prefers evicting non-loaded entries (pending, failed, expired)
771    /// before loaded entries.  This protects cached tile imagery from
772    /// being displaced by transient pending requests during fly-to
773    /// transitions where intermediate zoom levels briefly select many
774    /// tiles that will never finish loading before the camera moves on.
775    fn evict_if_full(&mut self) -> Vec<EvictedTile> {
776        let mut evicted = Vec::new();
777        while self.entries.len() >= self.max_entries {
778            // Eviction priority:
779            // 1. Failed entries (disposable, no in-flight work).
780            // 2. Non-pending entries via LRU (stale Loaded data from
781            //    old zoom levels).
782            // 3. Pending entries via LRU (last resort — cancels an
783            //    in-flight HTTP request, but prevents unbounded growth).
784            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    /// Evict entries until the cache is below capacity for a new `Pending` insert.
799    ///
800    /// This path is more continuity-biased than the generic eviction policy:
801    /// when admitting more pending work, prefer cancelling older pending work
802    /// before displacing renderable entries that can still provide exact or
803    /// fallback coverage.  As a last resort, the least-recently-used
804    /// renderable entry is evicted so that new destinations can always
805    /// make forward progress.  The pending-entry cap
806    /// ([`max_pending_entries`]) prevents this from over-displacing
807    /// loaded imagery during long fly-to transitions.
808    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    /// Walk the LRU list from head and return the first `Failed` entry.
828    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    /// Walk the LRU list from head and return the first non-renderable entry.
844    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    /// Walk the LRU list from head and return the first non-pending,
860    /// non-renderable entry.
861    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    /// Walk the LRU list from head and return the first `Pending` entry.
877    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    /// Walk the LRU list from head and return the first non-Pending entry.
893    ///
894    /// This skips `Pending` entries (in-flight requests) so that the
895    /// eviction policy prefers removing stale `Loaded` data over
896    /// cancelling active downloads.
897    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// ---------------------------------------------------------------------------
914// Tests
915// ---------------------------------------------------------------------------
916
917#[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    // -- Construction and basic queries -----------------------------------
944
945    #[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    // -- insert_pending ---------------------------------------------------
963
964    #[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        // Trying to re-insert as Pending should be a no-op.
989        assert!(!cache.insert_pending(id));
990        assert!(cache.get(&id).unwrap().is_loaded());
991    }
992
993    // -- promote ----------------------------------------------------------
994
995    #[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        // If the pending entry was evicted while the fetch was in
1009        // flight, promote should still insert the loaded data.
1010        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); // LRU order: [a]
1077        cache.insert_pending(b); // [a, b]
1078        cache.insert_pending(c); // [a, b, c]
1079
1080        // Promote 'a' to loaded -- should move it to MRU position.
1081        cache.promote(a, dummy_tile_response()); // [b, c, a]
1082
1083        // Promote 'b' to loaded -- should move it to MRU position.
1084        cache.promote(b, dummy_tile_response()); // [c, a, b]
1085
1086        // Insert 'd' -- pending admission should preserve renderable
1087        // continuity by evicting the oldest pending entry before loaded
1088        // imagery that can still be shown as exact or fallback coverage.
1089        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    // -- mark_failed ------------------------------------------------------
1157
1158    #[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    // -- remove -----------------------------------------------------------
1210
1211    #[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    // -- clear ------------------------------------------------------------
1228
1229    #[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        // Capacity is unchanged.
1241        assert_eq!(cache.capacity(), 10);
1242    }
1243
1244    // -- eviction ---------------------------------------------------------
1245
1246    #[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        // Without any promote/touch, eviction follows insertion order.
1266        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        // Only the last 3 should survive.
1274        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    // -- TileCacheEntry helpers -------------------------------------------
1283
1284    #[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    // -- Phase 5: O(1) LRU ordering tests ---------------------------------
1339
1340    #[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        // Touch `a` so `b` becomes the LRU.
1351        cache.touch(&a);
1352
1353        let d = TileId::new(3, 0, 0);
1354        cache.insert_pending(d);
1355
1356        // `b` should have been evicted (it was LRU).
1357        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        // Touch `a` so it becomes MRU; `b` is now LRU.
1372        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        // Remove the middle entry.
1390        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        // Insert a new entry � should not panic or corrupt the list.
1396        cache.insert_pending(TileId::new(5, 0, 0));
1397        assert_eq!(cache.len(), 5);
1398    }
1399
1400    // -- Phase 5: refresh_ttl (304 Not Modified) --------------------------
1401
1402    #[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        // Mark as reloading (simulating a refresh request).
1410        assert!(cache.start_reload(id));
1411        assert!(cache.get(&id).unwrap().is_reloading());
1412
1413        // Simulate 304 � refresh TTL.
1414        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    // -- Phase 5: revalidation_hint ---------------------------------------
1440
1441    #[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    // -- Phase 5: byte-budget eviction ------------------------------------
1461
1462    #[test]
1463    fn byte_budget_evicts_when_exceeded() {
1464        // Each dummy tile is 256*256*4 = 262144 bytes.
1465        let tile_bytes = 256 * 256 * 4;
1466        // Budget: 2 tiles worth of bytes.
1467        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        // Third tile exceeds byte budget � should evict `a`.
1483        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}