Skip to main content

ftui_widgets/
measure_cache.rs

1//! Measure cache for memoizing widget `measure()` results.
2//!
3//! This module provides [`MeasureCache`] which caches [`SizeConstraints`] returned by
4//! [`MeasurableWidget::measure()`] to avoid redundant computation during layout passes.
5//!
6//! # Overview
7//!
8//! During a single layout pass, the same widget may be queried multiple times with the
9//! same available size. Complex widgets like Tables with many cells can be expensive
10//! to measure. The cache eliminates this redundancy.
11//!
12//! # Usage
13//!
14//! ```ignore
15//! use ftui_core::geometry::Size;
16//! use ftui_widgets::{MeasureCache, WidgetId, SizeConstraints};
17//!
18//! let mut cache = MeasureCache::new(100);
19//!
20//! // First call computes the value
21//! let constraints = cache.get_or_compute(
22//!     WidgetId::from_ptr(&my_widget),
23//!     Size::new(80, 24),
24//!     || my_widget.measure(Size::new(80, 24)),
25//! );
26//!
27//! // Second call with same key returns cached value
28//! let cached = cache.get_or_compute(
29//!     WidgetId::from_ptr(&my_widget),
30//!     Size::new(80, 24),
31//!     || SizeConstraints::default(),
32//! );
33//! ```
34//!
35//! # Invalidation Strategies
36//!
37//! ## Generation-Based (Primary)
38//!
39//! Call [`MeasureCache::invalidate_all()`] after any state change affecting layout:
40//!
41//! ```ignore
42//! match msg {
43//!     Msg::DataChanged(data) => {
44//!         self.data = data;
45//!         self.measure_cache.invalidate_all();
46//!     }
47//!     Msg::Resize(_) => {
48//!         // Size is part of cache key, no invalidation needed!
49//!     }
50//! }
51//! ```
52//!
53//! ## Widget-Specific Invalidation
54//!
55//! When only one widget's content changes:
56//!
57//! ```ignore
58//! match msg {
59//!     Msg::ListItemAdded(item) => {
60//!         self.list.push(item);
61//!         self.measure_cache.invalidate_widget(WidgetId::from_hash(&"list"));
62//!     }
63//! }
64//! ```
65//!
66//! ## Content-Addressed (Automatic)
67//!
68//! Use content hash as widget ID for automatic invalidation:
69//!
70//! ```ignore
71//! impl MeasurableWidget for Paragraph<'_> {
72//!     fn widget_id(&self) -> WidgetId {
73//!         WidgetId::from_hash(&self.text)
74//!     }
75//! }
76//! ```
77//!
78//! # Cache Eviction
79//!
80//! The cache uses LFU (Least Frequently Used) eviction when at capacity.
81//! Access count tracks usage; least-accessed entries are evicted first.
82
83use ahash::AHashMap;
84use std::hash::{DefaultHasher, Hash, Hasher};
85
86use ftui_core::geometry::Size;
87
88use crate::measurable::SizeConstraints;
89
90/// Unique identifier for a widget instance.
91///
92/// Used as part of the cache key to distinguish between different widgets.
93///
94/// # Creating WidgetIds
95///
96/// ## From pointer (stable for widget lifetime):
97/// ```ignore
98/// WidgetId::from_ptr(&my_widget)
99/// ```
100///
101/// ## From content hash (stable across recreations):
102/// ```ignore
103/// WidgetId::from_hash(&my_widget.text)
104/// ```
105///
106/// ## From arbitrary u64:
107/// ```ignore
108/// WidgetId(42)
109/// ```
110#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
111pub struct WidgetId(pub u64);
112
113impl WidgetId {
114    /// Create a WidgetId from a memory address.
115    ///
116    /// Stable for the lifetime of the widget. Use when the widget instance
117    /// persists across multiple layout passes.
118    ///
119    /// # Note
120    ///
121    /// If the widget is recreated (e.g., in a loop), the pointer will change.
122    /// For such cases, prefer [`WidgetId::from_hash`].
123    #[inline]
124    pub fn from_ptr<T>(ptr: &T) -> Self {
125        Self(ptr as *const T as u64)
126    }
127
128    /// Create a WidgetId from a content hash.
129    ///
130    /// Stable across widget recreations as long as content is the same.
131    /// Use when widgets are ephemeral (created each frame) but content is stable.
132    #[inline]
133    pub fn from_hash<T: Hash>(value: &T) -> Self {
134        let mut hasher = DefaultHasher::new();
135        value.hash(&mut hasher);
136        Self(hasher.finish())
137    }
138}
139
140/// Cache key combining widget identity and available size.
141#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
142struct CacheKey {
143    widget_id: WidgetId,
144    available: Size,
145}
146
147/// Cached measurement result with metadata for eviction.
148#[derive(Clone, Debug)]
149struct CacheEntry {
150    /// The cached size constraints.
151    constraints: SizeConstraints,
152    /// Generation when this entry was created/updated.
153    generation: u64,
154    /// Access count for LFU eviction.
155    access_count: u32,
156}
157
158/// Statistics about cache performance.
159#[derive(Debug, Clone, Default)]
160pub struct CacheStats {
161    /// Number of entries currently in the cache.
162    pub entries: usize,
163    /// Total cache hits since creation or last reset.
164    pub hits: u64,
165    /// Total cache misses since creation or last reset.
166    pub misses: u64,
167    /// Hit rate as a fraction (0.0 to 1.0).
168    pub hit_rate: f64,
169}
170
171/// Thread-local cache for widget measure results.
172///
173/// Caches [`SizeConstraints`] returned by `MeasurableWidget::measure()` to
174/// avoid redundant computation during layout passes.
175///
176/// # Capacity
177///
178/// The cache has a fixed maximum capacity. When full, the least frequently used
179/// entries are evicted to make room for new ones.
180///
181/// # Generation-Based Invalidation
182///
183/// Each entry is tagged with a generation number. Calling [`invalidate_all()`]
184/// bumps the generation, making all existing entries stale. Stale entries are
185/// treated as cache misses and will be recomputed on next access.
186///
187/// [`invalidate_all()`]: MeasureCache::invalidate_all
188#[derive(Debug)]
189pub struct MeasureCache {
190    entries: AHashMap<CacheKey, CacheEntry>,
191    generation: u64,
192    max_entries: usize,
193    hits: u64,
194    misses: u64,
195}
196
197impl MeasureCache {
198    /// Create a new cache with the specified maximum capacity.
199    ///
200    /// # Arguments
201    ///
202    /// * `max_entries` - Maximum number of entries before LFU eviction occurs.
203    ///   A typical value is 100-1000 depending on widget complexity.
204    ///
205    /// # Example
206    ///
207    /// ```
208    /// use ftui_widgets::MeasureCache;
209    /// let cache = MeasureCache::new(256);
210    /// ```
211    #[inline]
212    pub fn new(max_entries: usize) -> Self {
213        Self {
214            entries: AHashMap::with_capacity(max_entries),
215            generation: 0,
216            max_entries,
217            hits: 0,
218            misses: 0,
219        }
220    }
221
222    /// Get cached result or compute and cache a new one.
223    ///
224    /// If a valid (same generation) cache entry exists for the given widget ID
225    /// and available size, returns it immediately. Otherwise, calls the `compute`
226    /// closure, caches the result, and returns it.
227    ///
228    /// # Arguments
229    ///
230    /// * `widget_id` - Unique identifier for the widget instance
231    /// * `available` - Available space for the measurement
232    /// * `compute` - Closure to compute the constraints if not cached
233    ///
234    /// # Example
235    ///
236    /// ```ignore
237    /// let constraints = cache.get_or_compute(
238    ///     WidgetId::from_ptr(&paragraph),
239    ///     Size::new(80, 24),
240    ///     || paragraph.measure(Size::new(80, 24)),
241    /// );
242    /// ```
243    pub fn get_or_compute<F>(
244        &mut self,
245        widget_id: WidgetId,
246        available: Size,
247        compute: F,
248    ) -> SizeConstraints
249    where
250        F: FnOnce() -> SizeConstraints,
251    {
252        let key = CacheKey {
253            widget_id,
254            available,
255        };
256
257        // Check for existing valid entry
258        if let Some(entry) = self.entries.get_mut(&key)
259            && entry.generation == self.generation
260        {
261            self.hits += 1;
262            entry.access_count = entry.access_count.saturating_add(1);
263            return entry.constraints;
264        }
265
266        // Cache miss - compute the value
267        self.misses += 1;
268        let constraints = compute();
269
270        // Evict if at capacity
271        if self.entries.len() >= self.max_entries {
272            self.evict_lfu();
273        }
274
275        // Insert new entry
276        self.entries.insert(
277            key,
278            CacheEntry {
279                constraints,
280                generation: self.generation,
281                access_count: 1,
282            },
283        );
284
285        constraints
286    }
287
288    /// Invalidate all entries by bumping the generation.
289    ///
290    /// Existing entries become stale and will be recomputed on next access.
291    /// This is an O(1) operation - entries are not immediately removed.
292    ///
293    /// # When to Call
294    ///
295    /// Call this after any state change that affects widget measurements:
296    /// - Model data changes
297    /// - Font/theme changes (if they affect sizing)
298    /// - Locale changes (if they affect text)
299    ///
300    /// # Note
301    ///
302    /// Resize events don't require invalidation because the available size
303    /// is part of the cache key.
304    #[inline]
305    pub fn invalidate_all(&mut self) {
306        self.generation = self.generation.wrapping_add(1);
307    }
308
309    /// Invalidate entries for a specific widget.
310    ///
311    /// Removes all cache entries associated with the given widget ID.
312    /// Use this for targeted invalidation when only one widget's content changes.
313    ///
314    /// # Arguments
315    ///
316    /// * `widget_id` - The widget whose entries should be invalidated
317    pub fn invalidate_widget(&mut self, widget_id: WidgetId) {
318        self.entries.retain(|k, _| k.widget_id != widget_id);
319    }
320
321    /// Get current cache statistics.
322    ///
323    /// Returns hit/miss counts and the current hit rate.
324    ///
325    /// # Example
326    ///
327    /// ```
328    /// use ftui_widgets::MeasureCache;
329    /// let cache = MeasureCache::new(100);
330    /// let stats = cache.stats();
331    /// println!("Hit rate: {:.1}%", stats.hit_rate * 100.0);
332    /// ```
333    pub fn stats(&self) -> CacheStats {
334        let total = self.hits + self.misses;
335        CacheStats {
336            entries: self.entries.len(),
337            hits: self.hits,
338            misses: self.misses,
339            hit_rate: if total > 0 {
340                self.hits as f64 / total as f64
341            } else {
342                0.0
343            },
344        }
345    }
346
347    /// Reset statistics counters to zero.
348    ///
349    /// Useful for measuring hit rate over a specific period (e.g., per frame).
350    #[inline]
351    pub fn reset_stats(&mut self) {
352        self.hits = 0;
353        self.misses = 0;
354    }
355
356    /// Clear all entries from the cache.
357    ///
358    /// Unlike [`invalidate_all()`], this immediately frees memory.
359    /// Use when transitioning to a completely different view.
360    ///
361    /// [`invalidate_all()`]: MeasureCache::invalidate_all
362    #[inline]
363    pub fn clear(&mut self) {
364        self.entries.clear();
365        self.generation = self.generation.wrapping_add(1);
366    }
367
368    /// Returns the current number of entries in the cache.
369    #[inline]
370    pub fn len(&self) -> usize {
371        self.entries.len()
372    }
373
374    /// Returns true if the cache is empty.
375    #[inline]
376    pub fn is_empty(&self) -> bool {
377        self.entries.is_empty()
378    }
379
380    /// Returns the maximum capacity of the cache.
381    #[inline]
382    pub fn capacity(&self) -> usize {
383        self.max_entries
384    }
385
386    /// Evict the least frequently used entry.
387    fn evict_lfu(&mut self) {
388        // Find entry with lowest access_count
389        if let Some(key) = self
390            .entries
391            .iter()
392            .min_by_key(|(_, e)| e.access_count)
393            .map(|(k, _)| *k)
394        {
395            self.entries.remove(&key);
396        }
397    }
398}
399
400impl Default for MeasureCache {
401    /// Creates a cache with default capacity of 256 entries.
402    fn default() -> Self {
403        Self::new(256)
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    // --- WidgetId tests ---
412
413    #[test]
414    fn widget_id_from_ptr_is_stable() {
415        let widget = 42u64;
416        let id1 = WidgetId::from_ptr(&widget);
417        let id2 = WidgetId::from_ptr(&widget);
418        assert_eq!(id1, id2);
419    }
420
421    #[test]
422    fn widget_id_from_hash_is_stable() {
423        let text = "hello world";
424        let id1 = WidgetId::from_hash(&text);
425        let id2 = WidgetId::from_hash(&text);
426        assert_eq!(id1, id2);
427    }
428
429    #[test]
430    fn widget_id_from_hash_differs_for_different_content() {
431        let id1 = WidgetId::from_hash(&"hello");
432        let id2 = WidgetId::from_hash(&"world");
433        assert_ne!(id1, id2);
434    }
435
436    // --- MeasureCache tests ---
437
438    #[test]
439    fn cache_returns_same_result() {
440        let mut cache = MeasureCache::new(100);
441        let widget_id = WidgetId(42);
442        let available = Size::new(80, 24);
443
444        let mut call_count = 0;
445        let compute = || {
446            call_count += 1;
447            SizeConstraints {
448                min: Size::new(10, 1),
449                preferred: Size::new(50, 5),
450                max: None,
451            }
452        };
453
454        let r1 = cache.get_or_compute(widget_id, available, compute);
455        let r2 = cache.get_or_compute(widget_id, available, || unreachable!("should not call"));
456
457        assert_eq!(r1, r2);
458        assert_eq!(call_count, 1); // Only called once
459    }
460
461    #[test]
462    fn different_size_is_cache_miss() {
463        let mut cache = MeasureCache::new(100);
464        let widget_id = WidgetId(42);
465
466        let mut call_count = 0;
467        let mut compute = || {
468            call_count += 1;
469            SizeConstraints {
470                min: Size::ZERO,
471                preferred: Size::new(call_count as u16, 1),
472                max: None,
473            }
474        };
475
476        cache.get_or_compute(widget_id, Size::new(80, 24), &mut compute);
477        cache.get_or_compute(widget_id, Size::new(120, 40), &mut compute);
478
479        assert_eq!(call_count, 2); // Called twice for different sizes
480    }
481
482    #[test]
483    fn different_widget_is_cache_miss() {
484        let mut cache = MeasureCache::new(100);
485        let available = Size::new(80, 24);
486
487        let mut call_count = 0;
488        let mut compute = || {
489            call_count += 1;
490            SizeConstraints::ZERO
491        };
492
493        cache.get_or_compute(WidgetId(1), available, &mut compute);
494        cache.get_or_compute(WidgetId(2), available, &mut compute);
495
496        assert_eq!(call_count, 2);
497    }
498
499    #[test]
500    fn invalidation_clears_cache() {
501        let mut cache = MeasureCache::new(100);
502        let widget_id = WidgetId(42);
503        let available = Size::new(80, 24);
504
505        let mut call_count = 0;
506        let mut compute = || {
507            call_count += 1;
508            SizeConstraints::ZERO
509        };
510
511        cache.get_or_compute(widget_id, available, &mut compute);
512        cache.invalidate_all();
513        cache.get_or_compute(widget_id, available, &mut compute);
514
515        assert_eq!(call_count, 2); // Re-computed after invalidation
516    }
517
518    #[test]
519    fn widget_specific_invalidation() {
520        let mut cache = MeasureCache::new(100);
521        let widget1 = WidgetId(1);
522        let widget2 = WidgetId(2);
523        let available = Size::new(80, 24);
524
525        let mut count1 = 0;
526        let mut count2 = 0;
527
528        cache.get_or_compute(widget1, available, || {
529            count1 += 1;
530            SizeConstraints::ZERO
531        });
532        cache.get_or_compute(widget2, available, || {
533            count2 += 1;
534            SizeConstraints::ZERO
535        });
536
537        // Invalidate only widget1
538        cache.invalidate_widget(widget1);
539
540        // widget1 should miss, widget2 should hit
541        cache.get_or_compute(widget1, available, || {
542            count1 += 1;
543            SizeConstraints::ZERO
544        });
545        cache.get_or_compute(widget2, available, || unreachable!("should hit"));
546
547        assert_eq!(count1, 2);
548        assert_eq!(count2, 1);
549    }
550
551    #[test]
552    fn lfu_eviction_works() {
553        let mut cache = MeasureCache::new(2); // Small cache
554
555        // Insert two entries
556        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
557        cache.get_or_compute(WidgetId(2), Size::new(10, 10), || SizeConstraints::ZERO);
558
559        // Access widget 1 again (increases its access count)
560        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || {
561            unreachable!("should hit")
562        });
563
564        // Insert third entry, should evict widget 2 (least accessed)
565        cache.get_or_compute(WidgetId(3), Size::new(10, 10), || SizeConstraints::ZERO);
566
567        assert_eq!(cache.len(), 2);
568
569        // Widget 2 should be evicted
570        let mut was_called = false;
571        cache.get_or_compute(WidgetId(2), Size::new(10, 10), || {
572            was_called = true;
573            SizeConstraints::ZERO
574        });
575        assert!(was_called, "widget 2 should have been evicted");
576
577        // Widget 1 should still be cached
578        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || {
579            unreachable!("widget 1 should still be cached")
580        });
581    }
582
583    #[test]
584    fn stats_track_hits_and_misses() {
585        let mut cache = MeasureCache::new(100);
586
587        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
588        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || unreachable!("hit"));
589        cache.get_or_compute(WidgetId(2), Size::new(10, 10), || SizeConstraints::ZERO);
590
591        let stats = cache.stats();
592        assert_eq!(stats.hits, 1);
593        assert_eq!(stats.misses, 2);
594        assert!((stats.hit_rate - 1.0 / 3.0).abs() < 0.01);
595    }
596
597    #[test]
598    fn reset_stats_clears_counters() {
599        let mut cache = MeasureCache::new(100);
600
601        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
602        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || unreachable!("hit"));
603
604        let stats = cache.stats();
605        assert_eq!(stats.hits, 1);
606        assert_eq!(stats.misses, 1);
607
608        cache.reset_stats();
609
610        let stats = cache.stats();
611        assert_eq!(stats.hits, 0);
612        assert_eq!(stats.misses, 0);
613        assert_eq!(stats.hit_rate, 0.0);
614    }
615
616    #[test]
617    fn clear_removes_all_entries() {
618        let mut cache = MeasureCache::new(100);
619
620        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
621        cache.get_or_compute(WidgetId(2), Size::new(10, 10), || SizeConstraints::ZERO);
622
623        assert_eq!(cache.len(), 2);
624
625        cache.clear();
626
627        assert_eq!(cache.len(), 0);
628        assert!(cache.is_empty());
629
630        // All entries should miss now
631        let mut was_called = false;
632        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || {
633            was_called = true;
634            SizeConstraints::ZERO
635        });
636        assert!(was_called);
637    }
638
639    #[test]
640    fn default_capacity_is_256() {
641        let cache = MeasureCache::default();
642        assert_eq!(cache.capacity(), 256);
643    }
644
645    #[test]
646    fn generation_wraps_around() {
647        let mut cache = MeasureCache::new(100);
648        cache.generation = u64::MAX;
649        cache.invalidate_all();
650        assert_eq!(cache.generation, 0);
651    }
652
653    // --- Property-like tests ---
654
655    #[test]
656    fn cache_is_deterministic() {
657        let mut cache1 = MeasureCache::new(100);
658        let mut cache2 = MeasureCache::new(100);
659
660        for i in 0..10u64 {
661            let id = WidgetId(i);
662            let size = Size::new((i * 10) as u16, (i * 5) as u16);
663            let c = SizeConstraints {
664                min: Size::new(i as u16, 1),
665                preferred: Size::new((i * 2) as u16, 2),
666                max: None,
667            };
668
669            cache1.get_or_compute(id, size, || c);
670            cache2.get_or_compute(id, size, || c);
671        }
672
673        // Same inputs should produce same stats
674        assert_eq!(cache1.stats().entries, cache2.stats().entries);
675        assert_eq!(cache1.stats().misses, cache2.stats().misses);
676    }
677
678    #[test]
679    fn widget_id_from_ptr_differs_for_different_objects() {
680        let a = 42u64;
681        let b = 42u64;
682        let id_a = WidgetId::from_ptr(&a);
683        let id_b = WidgetId::from_ptr(&b);
684        assert_ne!(id_a, id_b);
685    }
686
687    #[test]
688    fn new_cache_is_empty() {
689        let cache = MeasureCache::new(100);
690        assert!(cache.is_empty());
691        assert_eq!(cache.len(), 0);
692    }
693
694    #[test]
695    fn stats_zero_total_gives_zero_hit_rate() {
696        let cache = MeasureCache::new(100);
697        let stats = cache.stats();
698        assert_eq!(stats.hit_rate, 0.0);
699        assert_eq!(stats.entries, 0);
700    }
701
702    #[test]
703    fn hit_count_increments_on_each_access() {
704        let mut cache = MeasureCache::new(100);
705        let id = WidgetId(42);
706        let size = Size::new(80, 24);
707
708        // First access is a miss
709        cache.get_or_compute(id, size, || SizeConstraints::ZERO);
710
711        // Subsequent accesses are hits
712        for _ in 0..5 {
713            cache.get_or_compute(id, size, || unreachable!("should hit"));
714        }
715
716        let stats = cache.stats();
717        assert_eq!(stats.misses, 1);
718        assert_eq!(stats.hits, 5);
719    }
720
721    // ---- Edge-case tests (bd-2ncz7) ----
722
723    #[test]
724    fn edge_zero_capacity_cache() {
725        let mut cache = MeasureCache::new(0);
726        let id = WidgetId(1);
727        let size = Size::new(10, 10);
728
729        // Every call is a miss because capacity is 0
730        let mut calls = 0;
731        for _ in 0..3 {
732            cache.get_or_compute(id, size, || {
733                calls += 1;
734                SizeConstraints::ZERO
735            });
736        }
737        // With capacity 0, eviction fires before every insert,
738        // but the entry is still inserted (map has 0 capacity threshold).
739        // The entry exists after insert, so second call hits it.
740        let stats = cache.stats();
741        assert_eq!(stats.misses + stats.hits, 3);
742    }
743
744    #[test]
745    fn edge_capacity_one_evicts_on_second_widget() {
746        let mut cache = MeasureCache::new(1);
747
748        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
749        assert_eq!(cache.len(), 1);
750
751        // Second different widget should evict the first
752        cache.get_or_compute(WidgetId(2), Size::new(10, 10), || SizeConstraints::ZERO);
753        assert_eq!(cache.len(), 1);
754
755        // Widget 1 should be evicted
756        let mut was_called = false;
757        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || {
758            was_called = true;
759            SizeConstraints::ZERO
760        });
761        assert!(was_called, "widget 1 should have been evicted");
762    }
763
764    #[test]
765    fn edge_invalidate_all_multiple_times() {
766        let mut cache = MeasureCache::new(100);
767        let gen_before = cache.generation;
768        cache.invalidate_all();
769        cache.invalidate_all();
770        cache.invalidate_all();
771        assert_eq!(cache.generation, gen_before + 3);
772    }
773
774    #[test]
775    fn edge_invalidate_widget_nonexistent() {
776        let mut cache = MeasureCache::new(100);
777        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
778        assert_eq!(cache.len(), 1);
779
780        // Invalidating a widget that doesn't exist is a no-op
781        cache.invalidate_widget(WidgetId(999));
782        assert_eq!(cache.len(), 1);
783    }
784
785    #[test]
786    fn edge_invalidate_widget_removes_all_sizes() {
787        let mut cache = MeasureCache::new(100);
788        let id = WidgetId(42);
789
790        // Same widget, different available sizes → multiple entries
791        cache.get_or_compute(id, Size::new(80, 24), || SizeConstraints::ZERO);
792        cache.get_or_compute(id, Size::new(120, 40), || SizeConstraints::ZERO);
793        cache.get_or_compute(id, Size::new(200, 60), || SizeConstraints::ZERO);
794        assert_eq!(cache.len(), 3);
795
796        cache.invalidate_widget(id);
797        assert_eq!(cache.len(), 0);
798    }
799
800    #[test]
801    fn edge_stale_entry_treated_as_miss() {
802        let mut cache = MeasureCache::new(100);
803        let id = WidgetId(1);
804        let size = Size::new(10, 10);
805
806        cache.get_or_compute(id, size, || SizeConstraints::ZERO);
807        assert_eq!(cache.stats().misses, 1);
808        assert_eq!(cache.stats().hits, 0);
809
810        // Invalidate makes all entries stale
811        cache.invalidate_all();
812
813        // Same key should now be a miss (stale generation)
814        let mut called = false;
815        cache.get_or_compute(id, size, || {
816            called = true;
817            SizeConstraints::ZERO
818        });
819        assert!(called, "stale entry should be treated as miss");
820        assert_eq!(cache.stats().misses, 2);
821    }
822
823    #[test]
824    fn edge_lfu_equal_access_counts() {
825        let mut cache = MeasureCache::new(2);
826
827        // Both entries accessed exactly once
828        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
829        cache.get_or_compute(WidgetId(2), Size::new(10, 10), || SizeConstraints::ZERO);
830
831        // Insert a third — one of the first two gets evicted
832        cache.get_or_compute(WidgetId(3), Size::new(10, 10), || SizeConstraints::ZERO);
833        assert_eq!(cache.len(), 2);
834
835        // At least one of widget 1 or 2 is evicted
836        let mut evicted = 0;
837        for id_val in [1u64, 2u64] {
838            let mut called = false;
839            cache.get_or_compute(WidgetId(id_val), Size::new(10, 10), || {
840                called = true;
841                SizeConstraints::ZERO
842            });
843            if called {
844                evicted += 1;
845            }
846        }
847        assert!(evicted >= 1, "at least one entry should be evicted");
848    }
849
850    #[test]
851    fn edge_size_zero_as_cache_key() {
852        let mut cache = MeasureCache::new(100);
853        let id = WidgetId(1);
854
855        cache.get_or_compute(id, Size::ZERO, || SizeConstraints::ZERO);
856        // Should hit on second call
857        cache.get_or_compute(id, Size::ZERO, || unreachable!("should hit for Size::ZERO"));
858        assert_eq!(cache.stats().hits, 1);
859    }
860
861    #[test]
862    fn edge_widget_id_zero() {
863        let mut cache = MeasureCache::new(100);
864        cache.get_or_compute(WidgetId(0), Size::new(10, 10), || SizeConstraints::ZERO);
865        cache.get_or_compute(WidgetId(0), Size::new(10, 10), || {
866            unreachable!("should hit for WidgetId(0)")
867        });
868        assert_eq!(cache.stats().hits, 1);
869    }
870
871    #[test]
872    fn edge_clear_preserves_stats() {
873        let mut cache = MeasureCache::new(100);
874        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
875        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || unreachable!());
876
877        cache.clear();
878        assert!(cache.is_empty());
879        // Stats should still be preserved after clear
880        let stats = cache.stats();
881        assert_eq!(stats.hits, 1);
882        assert_eq!(stats.misses, 1);
883    }
884
885    #[test]
886    fn edge_reset_stats_preserves_entries() {
887        let mut cache = MeasureCache::new(100);
888        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
889        assert_eq!(cache.len(), 1);
890
891        cache.reset_stats();
892        assert_eq!(cache.len(), 1);
893        // Entry should still be cached
894        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || {
895            unreachable!("should hit")
896        });
897        assert_eq!(cache.stats().hits, 1);
898    }
899
900    #[test]
901    fn edge_entries_never_exceed_capacity() {
902        let cap = 5;
903        let mut cache = MeasureCache::new(cap);
904
905        for i in 0..100u64 {
906            cache.get_or_compute(WidgetId(i), Size::new(10, 10), || SizeConstraints::ZERO);
907            assert!(
908                cache.len() <= cap,
909                "len {} > capacity {} at i={}",
910                cache.len(),
911                cap,
912                i
913            );
914        }
915    }
916
917    #[test]
918    fn edge_clear_bumps_generation() {
919        let mut cache = MeasureCache::new(100);
920        let gen_before = cache.generation;
921        cache.clear();
922        assert_eq!(cache.generation, gen_before + 1);
923    }
924
925    #[test]
926    fn edge_invalidate_all_stale_but_still_counted_in_len() {
927        let mut cache = MeasureCache::new(100);
928        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
929        cache.get_or_compute(WidgetId(2), Size::new(10, 10), || SizeConstraints::ZERO);
930        assert_eq!(cache.len(), 2);
931
932        cache.invalidate_all();
933        // Stale entries are still in the map (not removed), just treated as misses
934        assert_eq!(cache.len(), 2);
935    }
936
937    #[test]
938    fn edge_invalidate_widget_does_not_affect_stats() {
939        let mut cache = MeasureCache::new(100);
940        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || SizeConstraints::ZERO);
941        cache.get_or_compute(WidgetId(1), Size::new(10, 10), || unreachable!());
942        assert_eq!(cache.stats().hits, 1);
943        assert_eq!(cache.stats().misses, 1);
944
945        cache.invalidate_widget(WidgetId(1));
946        // Stats unchanged
947        assert_eq!(cache.stats().hits, 1);
948        assert_eq!(cache.stats().misses, 1);
949    }
950
951    #[test]
952    fn edge_get_or_compute_returns_computed_value() {
953        let mut cache = MeasureCache::new(100);
954        let expected = SizeConstraints {
955            min: Size::new(5, 3),
956            preferred: Size::new(40, 10),
957            max: Some(Size::new(100, 50)),
958        };
959        let result = cache.get_or_compute(WidgetId(1), Size::new(80, 24), || expected);
960        assert_eq!(result, expected);
961    }
962
963    #[test]
964    fn edge_cache_stats_default() {
965        let stats = CacheStats::default();
966        assert_eq!(stats.entries, 0);
967        assert_eq!(stats.hits, 0);
968        assert_eq!(stats.misses, 0);
969        assert_eq!(stats.hit_rate, 0.0);
970    }
971
972    #[test]
973    fn edge_cache_stats_clone_debug() {
974        let stats = CacheStats {
975            entries: 5,
976            hits: 10,
977            misses: 3,
978            hit_rate: 0.769,
979        };
980        let cloned = stats.clone();
981        assert_eq!(cloned.entries, 5);
982        assert_eq!(cloned.hits, 10);
983        let _ = format!("{stats:?}");
984    }
985
986    #[test]
987    fn edge_measure_cache_debug() {
988        let cache = MeasureCache::new(100);
989        let debug = format!("{cache:?}");
990        assert!(debug.contains("MeasureCache"), "{debug}");
991    }
992
993    #[test]
994    fn edge_widget_id_copy_clone_hash_debug() {
995        let id = WidgetId(42);
996        let copied: WidgetId = id; // Copy (Copy implies Clone)
997        assert_eq!(copied, id);
998        let _ = format!("{id:?}");
999
1000        // Hash: same IDs should hash equally
1001        use std::collections::HashSet;
1002        let mut set = HashSet::new();
1003        set.insert(id);
1004        assert!(set.contains(&WidgetId(42)));
1005        assert!(!set.contains(&WidgetId(43)));
1006    }
1007}