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!(
219            self,
220            Self::Loaded(_) | Self::Expired(_) | Self::Reloading(_)
221        )
222    }
223
224    /// Returns `true` if the entry is in a stale-but-renderable expired state.
225    #[inline]
226    pub fn is_expired(&self) -> bool {
227        matches!(self, Self::Expired(_))
228    }
229
230    /// Returns `true` if the entry is actively reloading while keeping old data.
231    #[inline]
232    pub fn is_reloading(&self) -> bool {
233        matches!(self, Self::Reloading(_))
234    }
235
236    /// Returns `true` if the entry is in a failed state.
237    #[inline]
238    pub fn is_failed(&self) -> bool {
239        matches!(self, Self::Failed { .. })
240    }
241
242    /// Returns `true` if this entry still has renderable tile data.
243    #[inline]
244    pub fn is_renderable(&self) -> bool {
245        self.data().is_some()
246    }
247
248    /// Return the renderable tile payload, if any.
249    #[inline]
250    pub fn data(&self) -> Option<&TileData> {
251        self.cached_tile().map(|tile| &tile.data)
252    }
253
254    /// Return the loaded payload, if any.
255    #[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    /// Return freshness metadata for renderable entries.
265    #[inline]
266    pub fn freshness(&self) -> Option<&TileFreshness> {
267        self.cached_tile().map(|tile| &tile.freshness)
268    }
269
270    /// Return the wall-clock time when this entry was promoted to Loaded.
271    ///
272    /// Returns `None` for entries that have no renderable payload.
273    #[inline]
274    pub fn loaded_at(&self) -> Option<SystemTime> {
275        self.cached_tile().map(|tile| tile.loaded_at)
276    }
277}
278
279// ---------------------------------------------------------------------------
280// TileCache
281// ---------------------------------------------------------------------------
282
283/// A fixed-capacity LRU in-memory tile cache.
284///
285/// See the [module-level documentation](self) for eviction policy,
286/// lifecycle, and complexity details.
287pub struct TileCache {
288    /// Primary storage: tile ID -> (entry, LRU link).
289    entries: HashMap<TileId, (TileCacheEntry, LruLink)>,
290    /// Head of the LRU doubly-linked list (least recently used).
291    lru_head: Option<TileId>,
292    /// Tail of the LRU doubly-linked list (most recently used).
293    lru_tail: Option<TileId>,
294    /// Maximum number of entries before eviction kicks in.
295    max_entries: usize,
296    /// Optional byte budget.  When set, eviction also triggers when
297    /// total payload bytes exceed this limit.
298    max_bytes: Option<usize>,
299    /// Running total of payload bytes in the cache.
300    total_bytes: usize,
301}
302
303impl TileCache {
304    /// Create a new cache with the given maximum capacity.
305    ///
306    /// A `max_entries` of 0 is valid but degenerate -- every insertion
307    /// is immediately evicted.  Typical values range from 100 (low
308    /// memory) to 1000+ (large-screen / high-zoom).
309    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    /// Create a cache with both an entry-count and a byte-budget limit.
321    ///
322    /// Eviction triggers when **either** limit is exceeded.
323    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    // -- Queries ----------------------------------------------------------
335
336    /// Look up a tile in the cache.
337    ///
338    /// Returns `None` if the tile has never been inserted or has been
339    /// evicted.  Does **not** update the LRU order (read-only).
340    #[inline]
341    pub fn get(&self, id: &TileId) -> Option<&TileCacheEntry> {
342        self.entries.get(id).map(|(entry, _)| entry)
343    }
344
345    /// Returns `true` if the tile is present (in any state).
346    #[inline]
347    pub fn contains(&self, id: &TileId) -> bool {
348        self.entries.contains_key(id)
349    }
350
351    /// Number of entries currently in the cache.
352    #[inline]
353    pub fn len(&self) -> usize {
354        self.entries.len()
355    }
356
357    /// Whether the cache is empty.
358    #[inline]
359    pub fn is_empty(&self) -> bool {
360        self.entries.is_empty()
361    }
362
363    /// Maximum capacity configured at construction time.
364    #[inline]
365    pub fn capacity(&self) -> usize {
366        self.max_entries
367    }
368
369    /// Maximum number of pure `Pending` entries permitted at once.
370    #[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    /// Number of pure `Pending` entries currently retained.
382    #[inline]
383    fn pending_entry_count(&self) -> usize {
384        self.entries
385            .values()
386            .filter(|(entry, _)| entry.is_pending())
387            .count()
388    }
389
390    /// Total payload bytes currently tracked in the cache.
391    #[inline]
392    pub fn total_bytes(&self) -> usize {
393        self.total_bytes
394    }
395
396    /// The byte budget, if configured.
397    #[inline]
398    pub fn max_bytes(&self) -> Option<usize> {
399        self.max_bytes
400    }
401
402    /// Return the IDs of all pending tiles currently in the cache.
403    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    /// Return the IDs of all tiles that are actively pending or reloading.
411    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    /// Return the IDs of all tiles that are stale and eligible for refresh at `now`.
422    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    /// Return the IDs of all tiles that are stale and eligible for refresh now.
434    pub fn expired_ids(&self) -> Vec<TileId> {
435        self.expired_ids_at(SystemTime::now())
436    }
437
438    /// Return a state-count snapshot of the cache contents.
439    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    // -- LRU linked-list helpers (O(1)) -----------------------------------
462
463    /// Unlink a node from the doubly-linked list without removing it from
464    /// the `entries` HashMap.
465    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        // Clear own pointers.
489        if let Some((_, link)) = self.entries.get_mut(id) {
490            link.prev = None;
491            link.next = None;
492        }
493    }
494
495    /// Append a node (already in `entries`) to the MRU tail of the list.
496    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            // Empty list.
508            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    // -- Mutations --------------------------------------------------------
518
519    /// Mark an existing entry as recently used (O(1)).
520    ///
521    /// Returns `true` if the tile was present and moved to the MRU end.
522    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    /// Insert a [`Pending`](TileCacheEntry::Pending) entry for `id` and report
532    /// any evictions that occurred.
533    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    /// Insert a [`Pending`](TileCacheEntry::Pending) entry for `id`.
565    ///
566    /// Returns `true` if the entry was newly inserted.
567    pub fn insert_pending(&mut self, id: TileId) -> bool {
568        self.insert_pending_with_eviction(id).inserted
569    }
570
571    /// Promote an entry to [`Loaded`](TileCacheEntry::Loaded) and move
572    /// it to the most-recently-used position, reporting any evicted tiles.
573    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            // Remove old byte count before replacing.
600            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)] // Entry guaranteed to exist after insert/touch above.
610        {
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        // Byte-budget eviction: continue evicting LRU entries until we
620        // are within budget.
621        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; // Don't evict the entry we just promoted.
627                    }
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    /// Promote an entry to [`Loaded`](TileCacheEntry::Loaded) and move
647    /// it to the most-recently-used position.
648    pub fn promote(&mut self, id: TileId, response: TileResponse) {
649        let _ = self.promote_with_eviction(id, response);
650    }
651
652    /// Mark a loaded tile as expired while keeping it renderable.
653    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    /// Transition a stale tile into reloading while retaining its old payload.
670    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    /// Refresh the TTL of a reloading/expired entry without replacing its
689    /// payload.
690    ///
691    /// This is the cache-side handler for `304 Not Modified` responses.
692    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    /// Return the revalidation hint (etag + last-modified) for a tile.
714    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    /// Mark the tile as failed, preserving stale renderable payload when possible.
724    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    /// Cancel an in-flight reload, demoting the entry back to `Expired`
749    /// while preserving its renderable payload.
750    ///
751    /// Returns `true` if the entry was `Reloading` and was demoted.
752    /// Has no effect on entries in any other state.
753    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    /// Remove a tile from the cache (O(1)).
768    ///
769    /// Returns `true` if the tile was present and removed.
770    pub fn remove(&mut self, id: &TileId) -> bool {
771        self.remove_and_return(*id).is_some()
772    }
773
774    /// Remove all entries from the cache.
775    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    // -- Internal ---------------------------------------------------------
787
788    /// Remove a tile and return its eviction record.
789    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    /// Evict entries until the cache is below capacity.
802    ///
803    /// Prefers evicting non-loaded entries (pending, failed, expired)
804    /// before loaded entries.  This protects cached tile imagery from
805    /// being displaced by transient pending requests during fly-to
806    /// transitions where intermediate zoom levels briefly select many
807    /// tiles that will never finish loading before the camera moves on.
808    fn evict_if_full(&mut self) -> Vec<EvictedTile> {
809        let mut evicted = Vec::new();
810        while self.entries.len() >= self.max_entries {
811            // Eviction priority:
812            // 1. Failed entries (disposable, no in-flight work).
813            // 2. Non-pending entries via LRU (stale Loaded data from
814            //    old zoom levels).
815            // 3. Pending entries via LRU (last resort — cancels an
816            //    in-flight HTTP request, but prevents unbounded growth).
817            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    /// Evict entries until the cache is below capacity for a new `Pending` insert.
833    ///
834    /// This path is more continuity-biased than the generic eviction policy:
835    /// when admitting more pending work, prefer cancelling older pending work
836    /// before displacing renderable entries that can still provide exact or
837    /// fallback coverage.  As a last resort, the least-recently-used
838    /// renderable entry is evicted so that new destinations can always
839    /// make forward progress.  The pending-entry cap
840    /// ([`max_pending_entries`]) prevents this from over-displacing
841    /// loaded imagery during long fly-to transitions.
842    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    /// Walk the LRU list from head and return the first `Failed` entry.
862    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    /// Walk the LRU list from head and return the first non-renderable entry.
878    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    /// Walk the LRU list from head and return the first non-pending,
894    /// non-renderable entry.
895    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    /// Walk the LRU list from head and return the first `Pending` entry.
911    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    /// Walk the LRU list from head and return the first non-Pending entry.
927    ///
928    /// This skips `Pending` entries (in-flight requests) so that the
929    /// eviction policy prefers removing stale `Loaded` data over
930    /// cancelling active downloads.
931    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// ---------------------------------------------------------------------------
948// Tests
949// ---------------------------------------------------------------------------
950
951#[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    // -- Construction and basic queries -----------------------------------
978
979    #[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    // -- insert_pending ---------------------------------------------------
997
998    #[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        // Trying to re-insert as Pending should be a no-op.
1023        assert!(!cache.insert_pending(id));
1024        assert!(cache.get(&id).unwrap().is_loaded());
1025    }
1026
1027    // -- promote ----------------------------------------------------------
1028
1029    #[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        // If the pending entry was evicted while the fetch was in
1043        // flight, promote should still insert the loaded data.
1044        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); // LRU order: [a]
1114        cache.insert_pending(b); // [a, b]
1115        cache.insert_pending(c); // [a, b, c]
1116
1117        // Promote 'a' to loaded -- should move it to MRU position.
1118        cache.promote(a, dummy_tile_response()); // [b, c, a]
1119
1120        // Promote 'b' to loaded -- should move it to MRU position.
1121        cache.promote(b, dummy_tile_response()); // [c, a, b]
1122
1123        // Insert 'd' -- pending admission should preserve renderable
1124        // continuity by evicting the oldest pending entry before loaded
1125        // imagery that can still be shown as exact or fallback coverage.
1126        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    // -- mark_failed ------------------------------------------------------
1218
1219    #[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    // -- remove -----------------------------------------------------------
1271
1272    #[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    // -- clear ------------------------------------------------------------
1289
1290    #[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        // Capacity is unchanged.
1302        assert_eq!(cache.capacity(), 10);
1303    }
1304
1305    // -- eviction ---------------------------------------------------------
1306
1307    #[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        // Without any promote/touch, eviction follows insertion order.
1327        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        // Only the last 3 should survive.
1335        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    // -- TileCacheEntry helpers -------------------------------------------
1344
1345    #[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    // -- Phase 5: O(1) LRU ordering tests ---------------------------------
1400
1401    #[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        // Touch `a` so `b` becomes the LRU.
1412        cache.touch(&a);
1413
1414        let d = TileId::new(3, 0, 0);
1415        cache.insert_pending(d);
1416
1417        // `b` should have been evicted (it was LRU).
1418        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        // Touch `a` so it becomes MRU; `b` is now LRU.
1433        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        // Remove the middle entry.
1451        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        // Insert a new entry � should not panic or corrupt the list.
1457        cache.insert_pending(TileId::new(5, 0, 0));
1458        assert_eq!(cache.len(), 5);
1459    }
1460
1461    // -- Phase 5: refresh_ttl (304 Not Modified) --------------------------
1462
1463    #[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        // Mark as reloading (simulating a refresh request).
1471        assert!(cache.start_reload(id));
1472        assert!(cache.get(&id).unwrap().is_reloading());
1473
1474        // Simulate 304 � refresh TTL.
1475        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    // -- Phase 5: revalidation_hint ---------------------------------------
1501
1502    #[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    // -- Phase 5: byte-budget eviction ------------------------------------
1522
1523    #[test]
1524    fn byte_budget_evicts_when_exceeded() {
1525        // Each dummy tile is 256*256*4 = 262144 bytes.
1526        let tile_bytes = 256 * 256 * 4;
1527        // Budget: 2 tiles worth of bytes.
1528        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        // Third tile exceeds byte budget � should evict `a`.
1544        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}