Skip to main content

ftui_layout/
cache.rs

1//! Layout cache for memoizing layout computation results.
2//!
3//! This module provides [`LayoutCache`] which caches the `Vec<Rect>` results from
4//! layout computations to avoid redundant constraint solving during rendering.
5//!
6//! # Overview
7//!
8//! Layout computation (constraint solving, flex distribution) can be expensive for
9//! complex nested layouts. During a single frame, the same layout may be queried
10//! multiple times with identical parameters. The cache eliminates this redundancy.
11//!
12//! # Usage
13//!
14//! ```ignore
15//! use ftui_layout::{Flex, Constraint, LayoutCache, LayoutCacheKey, Direction};
16//! use ftui_core::geometry::Rect;
17//!
18//! let mut cache = LayoutCache::new(64);
19//!
20//! let flex = Flex::horizontal()
21//!     .constraints([Constraint::Percentage(50.0), Constraint::Fill]);
22//!
23//! let area = Rect::new(0, 0, 80, 24);
24//!
25//! // First call computes and caches
26//! let rects = flex.split_cached(area, &mut cache);
27//!
28//! // Second call returns cached result
29//! let cached = flex.split_cached(area, &mut cache);
30//! ```
31//!
32//! # Invalidation
33//!
34//! ## Generation-Based (Primary)
35//!
36//! Call [`LayoutCache::invalidate_all()`] after any state change affecting layouts:
37//!
38//! ```ignore
39//! match msg {
40//!     Msg::DataChanged(_) => {
41//!         self.layout_cache.invalidate_all();
42//!     }
43//!     Msg::Resize(_) => {
44//!         // Area is part of cache key, no invalidation needed!
45//!     }
46//! }
47//! ```
48//!
49//! # Cache Eviction
50//!
51//! The cache uses LRU (Least Recently Used) eviction when at capacity.
52
53use std::collections::HashMap;
54use std::hash::{DefaultHasher, Hash, Hasher};
55
56use ftui_core::geometry::Rect;
57
58use crate::{Constraint, Direction, LayoutSizeHint};
59
60/// Key for layout cache lookups.
61///
62/// Includes all parameters that affect layout computation:
63/// - The available area (stored as components for Hash)
64/// - A fingerprint of all constraints
65/// - The layout direction
66/// - Optionally, a fingerprint of intrinsic size hints
67#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
68pub struct LayoutCacheKey {
69    /// Area x-coordinate.
70    pub area_x: u16,
71    /// Area y-coordinate.
72    pub area_y: u16,
73    /// Area width.
74    pub area_width: u16,
75    /// Area height.
76    pub area_height: u16,
77    /// Hash fingerprint of constraints.
78    pub constraints_hash: u64,
79    /// Layout direction.
80    pub direction: Direction,
81    /// Hash fingerprint of intrinsic sizes (if using FitContent).
82    pub intrinsics_hash: Option<u64>,
83}
84
85impl LayoutCacheKey {
86    /// Create a new cache key from layout parameters.
87    ///
88    /// # Arguments
89    ///
90    /// * `area` - The available rectangle for layout
91    /// * `constraints` - The constraint list
92    /// * `direction` - Horizontal or Vertical layout
93    /// * `intrinsics` - Optional size hints for FitContent constraints
94    pub fn new(
95        area: Rect,
96        constraints: &[Constraint],
97        direction: Direction,
98        intrinsics: Option<&[LayoutSizeHint]>,
99    ) -> Self {
100        Self {
101            area_x: area.x,
102            area_y: area.y,
103            area_width: area.width,
104            area_height: area.height,
105            constraints_hash: Self::hash_constraints(constraints),
106            direction,
107            intrinsics_hash: intrinsics.map(Self::hash_intrinsics),
108        }
109    }
110
111    /// Reconstruct the area Rect from cached components.
112    #[inline]
113    pub fn area(&self) -> Rect {
114        Rect::new(self.area_x, self.area_y, self.area_width, self.area_height)
115    }
116
117    /// Hash a slice of constraints.
118    fn hash_constraints(constraints: &[Constraint]) -> u64 {
119        let mut hasher = DefaultHasher::new();
120        for c in constraints {
121            // Hash each constraint's discriminant and value
122            std::mem::discriminant(c).hash(&mut hasher);
123            match c {
124                Constraint::Fixed(v) => v.hash(&mut hasher),
125                Constraint::Percentage(p) => p.to_bits().hash(&mut hasher),
126                Constraint::Min(v) => v.hash(&mut hasher),
127                Constraint::Max(v) => v.hash(&mut hasher),
128                Constraint::Ratio(n, d) => {
129                    fn gcd(mut a: u32, mut b: u32) -> u32 {
130                        while b != 0 {
131                            let t = b;
132                            b = a % b;
133                            a = t;
134                        }
135                        a
136                    }
137                    let divisor = gcd(*n, *d);
138                    if let (Some(n_div), Some(d_div)) =
139                        (n.checked_div(divisor), d.checked_div(divisor))
140                    {
141                        n_div.hash(&mut hasher);
142                        d_div.hash(&mut hasher);
143                    } else {
144                        n.hash(&mut hasher);
145                        d.hash(&mut hasher);
146                    }
147                }
148                Constraint::Fill => {}
149                Constraint::FitContent => {}
150                Constraint::FitContentBounded { min, max } => {
151                    min.hash(&mut hasher);
152                    max.hash(&mut hasher);
153                }
154                Constraint::FitMin => {}
155            }
156        }
157        hasher.finish()
158    }
159
160    /// Hash a slice of intrinsic size hints.
161    fn hash_intrinsics(intrinsics: &[LayoutSizeHint]) -> u64 {
162        let mut hasher = DefaultHasher::new();
163        for hint in intrinsics {
164            hint.min.hash(&mut hasher);
165            hint.preferred.hash(&mut hasher);
166            hint.max.hash(&mut hasher);
167        }
168        hasher.finish()
169    }
170}
171
172/// Cached layout result with metadata for eviction.
173#[derive(Clone, Debug)]
174struct CachedLayoutEntry {
175    /// The cached layout rectangles.
176    chunks: Vec<Rect>,
177    /// Generation when this entry was created/updated.
178    generation: u64,
179    /// Access count for LRU eviction.
180    access_count: u32,
181}
182
183/// Statistics about layout cache performance.
184#[derive(Debug, Clone, Default)]
185pub struct LayoutCacheStats {
186    /// Number of entries currently in the cache.
187    pub entries: usize,
188    /// Total cache hits since creation or last reset.
189    pub hits: u64,
190    /// Total cache misses since creation or last reset.
191    pub misses: u64,
192    /// Hit rate as a fraction (0.0 to 1.0).
193    pub hit_rate: f64,
194}
195
196/// Cache for layout computation results.
197///
198/// Stores `Vec<Rect>` results keyed by [`LayoutCacheKey`] to avoid redundant
199/// constraint solving during rendering.
200///
201/// # Capacity
202///
203/// The cache has a fixed maximum capacity. When full, the least recently used
204/// entries are evicted to make room for new ones.
205///
206/// # Generation-Based Invalidation
207///
208/// Each entry is tagged with a generation number. Calling [`invalidate_all()`]
209/// bumps the generation, making all existing entries stale.
210///
211/// [`invalidate_all()`]: LayoutCache::invalidate_all
212#[derive(Debug)]
213pub struct LayoutCache {
214    entries: HashMap<LayoutCacheKey, CachedLayoutEntry>,
215    generation: u64,
216    max_entries: usize,
217    hits: u64,
218    misses: u64,
219}
220
221impl LayoutCache {
222    /// Create a new cache with the specified maximum capacity.
223    ///
224    /// # Arguments
225    ///
226    /// * `max_entries` - Maximum number of entries before LRU eviction occurs.
227    ///   A typical value is 64-256 for most UIs.
228    ///
229    /// # Example
230    ///
231    /// ```
232    /// use ftui_layout::LayoutCache;
233    /// let cache = LayoutCache::new(64);
234    /// ```
235    #[inline]
236    pub fn new(max_entries: usize) -> Self {
237        Self {
238            entries: HashMap::with_capacity(max_entries),
239            generation: 0,
240            max_entries,
241            hits: 0,
242            misses: 0,
243        }
244    }
245
246    /// Get cached layout or compute and cache a new one.
247    ///
248    /// If a valid (same generation) cache entry exists for the given key,
249    /// returns a clone of it. Otherwise, calls the `compute` closure,
250    /// caches the result, and returns it.
251    ///
252    /// # Arguments
253    ///
254    /// * `key` - The cache key identifying this layout computation
255    /// * `compute` - Closure to compute the layout if not cached
256    ///
257    /// # Example
258    ///
259    /// ```ignore
260    /// let key = LayoutCacheKey::new(area, &constraints, Direction::Horizontal, None);
261    /// let rects = cache.get_or_compute(key, || flex.split(area));
262    /// ```
263    pub fn get_or_compute<F>(&mut self, key: LayoutCacheKey, compute: F) -> Vec<Rect>
264    where
265        F: FnOnce() -> Vec<Rect>,
266    {
267        // Check for existing valid entry
268        if let Some(entry) = self.entries.get_mut(&key)
269            && entry.generation == self.generation
270        {
271            self.hits += 1;
272            entry.access_count = entry.access_count.saturating_add(1);
273            return entry.chunks.clone();
274        }
275
276        // Cache miss - compute the value
277        self.misses += 1;
278        let chunks = compute();
279
280        // Evict if at capacity
281        if self.entries.len() >= self.max_entries {
282            self.evict_lru();
283        }
284
285        // Insert new entry
286        self.entries.insert(
287            key,
288            CachedLayoutEntry {
289                chunks: chunks.clone(),
290                generation: self.generation,
291                access_count: 1,
292            },
293        );
294
295        chunks
296    }
297
298    /// Invalidate all entries by bumping the generation.
299    ///
300    /// Existing entries become stale and will be recomputed on next access.
301    /// This is an O(1) operation - entries are not immediately removed.
302    ///
303    /// # When to Call
304    ///
305    /// Call this after any state change that affects layout:
306    /// - Model data changes that affect widget content
307    /// - Theme/font changes that affect sizing
308    ///
309    /// # Note
310    ///
311    /// Resize events don't require invalidation because the area
312    /// is part of the cache key.
313    #[inline]
314    pub fn invalidate_all(&mut self) {
315        self.generation = self.generation.wrapping_add(1);
316    }
317
318    /// Get current cache statistics.
319    ///
320    /// Returns hit/miss counts and the current hit rate.
321    pub fn stats(&self) -> LayoutCacheStats {
322        let total = self.hits + self.misses;
323        LayoutCacheStats {
324            entries: self.entries.len(),
325            hits: self.hits,
326            misses: self.misses,
327            hit_rate: if total > 0 {
328                self.hits as f64 / total as f64
329            } else {
330                0.0
331            },
332        }
333    }
334
335    /// Reset statistics counters to zero.
336    ///
337    /// Useful for measuring hit rate over a specific period (e.g., per frame).
338    #[inline]
339    pub fn reset_stats(&mut self) {
340        self.hits = 0;
341        self.misses = 0;
342    }
343
344    /// Clear all entries from the cache.
345    ///
346    /// Unlike [`invalidate_all()`], this immediately frees memory.
347    ///
348    /// [`invalidate_all()`]: LayoutCache::invalidate_all
349    #[inline]
350    pub fn clear(&mut self) {
351        self.entries.clear();
352        self.generation = self.generation.wrapping_add(1);
353    }
354
355    /// Returns the current number of entries in the cache.
356    #[inline]
357    pub fn len(&self) -> usize {
358        self.entries.len()
359    }
360
361    /// Returns true if the cache is empty.
362    #[inline]
363    pub fn is_empty(&self) -> bool {
364        self.entries.is_empty()
365    }
366
367    /// Returns the maximum capacity of the cache.
368    #[inline]
369    pub fn capacity(&self) -> usize {
370        self.max_entries
371    }
372
373    /// Evict the least recently used entry.
374    fn evict_lru(&mut self) {
375        if let Some(key) = self
376            .entries
377            .iter()
378            .min_by_key(|(_, e)| e.access_count)
379            .map(|(k, _)| *k)
380        {
381            self.entries.remove(&key);
382        }
383    }
384}
385
386impl Default for LayoutCache {
387    /// Creates a cache with default capacity of 64 entries.
388    fn default() -> Self {
389        Self::new(64)
390    }
391}
392
393// ---------------------------------------------------------------------------
394// Coherence Cache: Temporal Stability for Layout Rounding
395// ---------------------------------------------------------------------------
396
397/// Identity for a layout computation, used as key in the coherence cache.
398///
399/// This is a subset of [`LayoutCacheKey`] that identifies a *layout slot*
400/// independently of the available area. Two computations with the same
401/// `CoherenceId` but different area sizes represent the same logical layout
402/// being re-rendered at a different terminal size.
403#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
404pub struct CoherenceId {
405    /// Hash fingerprint of constraints.
406    pub constraints_hash: u64,
407    /// Layout direction.
408    pub direction: Direction,
409}
410
411impl CoherenceId {
412    /// Create a coherence ID from layout parameters.
413    pub fn new(constraints: &[Constraint], direction: Direction) -> Self {
414        Self {
415            constraints_hash: LayoutCacheKey::hash_constraints(constraints),
416            direction,
417        }
418    }
419
420    /// Create a coherence ID from an existing cache key.
421    pub fn from_cache_key(key: &LayoutCacheKey) -> Self {
422        Self {
423            constraints_hash: key.constraints_hash,
424            direction: key.direction,
425        }
426    }
427}
428
429/// Stores previous layout allocations for temporal coherence.
430///
431/// When terminal size changes, the layout solver re-computes positions from
432/// scratch. Without coherence, rounding tie-breaks can cause widgets to
433/// "jump" between frames even when the total size change is small.
434///
435/// The `CoherenceCache` feeds previous allocations to
436/// [`round_layout_stable`](crate::round_layout_stable) so that tie-breaking
437/// favours keeping elements where they were.
438///
439/// # Usage
440///
441/// ```ignore
442/// use ftui_layout::{CoherenceCache, CoherenceId, round_layout_stable, Constraint, Direction};
443///
444/// let mut coherence = CoherenceCache::new(64);
445///
446/// let id = CoherenceId::new(&constraints, Direction::Horizontal);
447/// let prev = coherence.get(&id);
448/// let alloc = round_layout_stable(&targets, total, prev);
449/// coherence.store(id, alloc.clone());
450/// ```
451///
452/// # Eviction
453///
454/// Entries are evicted on a least-recently-stored basis when the cache
455/// reaches capacity. The cache does not grow unboundedly.
456#[derive(Debug)]
457pub struct CoherenceCache {
458    entries: HashMap<CoherenceId, CoherenceEntry>,
459    max_entries: usize,
460    /// Monotonic counter for LRU eviction.
461    tick: u64,
462}
463
464#[derive(Debug, Clone)]
465struct CoherenceEntry {
466    /// Previous allocation sizes.
467    allocation: Vec<u16>,
468    /// Tick when this entry was last stored.
469    last_stored: u64,
470}
471
472impl CoherenceCache {
473    /// Create a new coherence cache with the specified capacity.
474    pub fn new(max_entries: usize) -> Self {
475        Self {
476            entries: HashMap::with_capacity(max_entries.min(256)),
477            max_entries,
478            tick: 0,
479        }
480    }
481
482    /// Retrieve the previous allocation for a layout, if available.
483    ///
484    /// Returns `Some(allocation)` suitable for passing directly to
485    /// [`round_layout_stable`](crate::round_layout_stable).
486    #[inline]
487    pub fn get(&self, id: &CoherenceId) -> Option<Vec<u16>> {
488        self.entries.get(id).map(|e| e.allocation.clone())
489    }
490
491    /// Store a layout allocation for future coherence lookups.
492    ///
493    /// If the cache is at capacity, evicts the oldest entry.
494    pub fn store(&mut self, id: CoherenceId, allocation: Vec<u16>) {
495        self.tick = self.tick.wrapping_add(1);
496
497        if self.entries.len() >= self.max_entries && !self.entries.contains_key(&id) {
498            self.evict_oldest();
499        }
500
501        self.entries.insert(
502            id,
503            CoherenceEntry {
504                allocation,
505                last_stored: self.tick,
506            },
507        );
508    }
509
510    /// Clear all stored allocations.
511    #[inline]
512    pub fn clear(&mut self) {
513        self.entries.clear();
514    }
515
516    /// Number of entries in the cache.
517    #[inline]
518    pub fn len(&self) -> usize {
519        self.entries.len()
520    }
521
522    /// Returns true if the cache is empty.
523    #[inline]
524    pub fn is_empty(&self) -> bool {
525        self.entries.is_empty()
526    }
527
528    /// Compute displacement between a new allocation and the previously stored one.
529    ///
530    /// Returns `(sum_displacement, max_displacement)` in cells.
531    /// If no previous allocation exists for the ID, returns `(0, 0)`.
532    pub fn displacement(&self, id: &CoherenceId, new_alloc: &[u16]) -> (u64, u32) {
533        match self.entries.get(id) {
534            Some(entry) => {
535                let prev = &entry.allocation;
536                let len = prev.len().min(new_alloc.len());
537                let mut sum: u64 = 0;
538                let mut max: u32 = 0;
539                for i in 0..len {
540                    let d = (new_alloc[i] as i32 - prev[i] as i32).unsigned_abs();
541                    sum += u64::from(d);
542                    max = max.max(d);
543                }
544                // If lengths differ, count the extra elements as full displacement
545                for &v in &prev[len..] {
546                    sum += u64::from(v);
547                    max = max.max(u32::from(v));
548                }
549                for &v in &new_alloc[len..] {
550                    sum += u64::from(v);
551                    max = max.max(u32::from(v));
552                }
553                (sum, max)
554            }
555            None => (0, 0),
556        }
557    }
558
559    fn evict_oldest(&mut self) {
560        if let Some(key) = self
561            .entries
562            .iter()
563            .min_by_key(|(_, e)| e.last_stored)
564            .map(|(k, _)| *k)
565        {
566            self.entries.remove(&key);
567        }
568    }
569}
570
571impl Default for CoherenceCache {
572    fn default() -> Self {
573        Self::new(64)
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    fn make_key(width: u16, height: u16) -> LayoutCacheKey {
582        LayoutCacheKey::new(
583            Rect::new(0, 0, width, height),
584            &[Constraint::Percentage(50.0), Constraint::Fill],
585            Direction::Horizontal,
586            None,
587        )
588    }
589
590    fn should_not_call(label: &str) -> Vec<Rect> {
591        unreachable!("{label}");
592    }
593
594    // --- LayoutCacheKey tests ---
595
596    #[test]
597    fn same_params_produce_same_key() {
598        let k1 = make_key(80, 24);
599        let k2 = make_key(80, 24);
600        assert_eq!(k1, k2);
601    }
602
603    #[test]
604    fn different_area_different_key() {
605        let k1 = make_key(80, 24);
606        let k2 = make_key(120, 40);
607        assert_ne!(k1, k2);
608    }
609
610    #[test]
611    fn different_constraints_different_key() {
612        let k1 = LayoutCacheKey::new(
613            Rect::new(0, 0, 80, 24),
614            &[Constraint::Fixed(20)],
615            Direction::Horizontal,
616            None,
617        );
618        let k2 = LayoutCacheKey::new(
619            Rect::new(0, 0, 80, 24),
620            &[Constraint::Fixed(30)],
621            Direction::Horizontal,
622            None,
623        );
624        assert_ne!(k1, k2);
625    }
626
627    #[test]
628    fn different_direction_different_key() {
629        let k1 = LayoutCacheKey::new(
630            Rect::new(0, 0, 80, 24),
631            &[Constraint::Fill],
632            Direction::Horizontal,
633            None,
634        );
635        let k2 = LayoutCacheKey::new(
636            Rect::new(0, 0, 80, 24),
637            &[Constraint::Fill],
638            Direction::Vertical,
639            None,
640        );
641        assert_ne!(k1, k2);
642    }
643
644    #[test]
645    fn different_intrinsics_different_key() {
646        let hints1 = [LayoutSizeHint {
647            min: 10,
648            preferred: 20,
649            max: None,
650        }];
651        let hints2 = [LayoutSizeHint {
652            min: 10,
653            preferred: 30,
654            max: None,
655        }];
656
657        let k1 = LayoutCacheKey::new(
658            Rect::new(0, 0, 80, 24),
659            &[Constraint::FitContent],
660            Direction::Horizontal,
661            Some(&hints1),
662        );
663        let k2 = LayoutCacheKey::new(
664            Rect::new(0, 0, 80, 24),
665            &[Constraint::FitContent],
666            Direction::Horizontal,
667            Some(&hints2),
668        );
669        assert_ne!(k1, k2);
670    }
671
672    // --- LayoutCache tests ---
673
674    #[test]
675    fn cache_returns_same_result() {
676        let mut cache = LayoutCache::new(100);
677        let key = make_key(80, 24);
678
679        let mut compute_count = 0;
680        let compute = || {
681            compute_count += 1;
682            vec![Rect::new(0, 0, 40, 24), Rect::new(40, 0, 40, 24)]
683        };
684
685        let r1 = cache.get_or_compute(key, compute);
686        let r2 = cache.get_or_compute(key, || should_not_call("should not call"));
687
688        assert_eq!(r1, r2);
689        assert_eq!(compute_count, 1);
690    }
691
692    #[test]
693    fn different_area_is_cache_miss() {
694        let mut cache = LayoutCache::new(100);
695
696        let mut compute_count = 0;
697        let mut compute = || {
698            compute_count += 1;
699            vec![Rect::default()]
700        };
701
702        let k1 = make_key(80, 24);
703        let k2 = make_key(120, 40);
704
705        cache.get_or_compute(k1, &mut compute);
706        cache.get_or_compute(k2, &mut compute);
707
708        assert_eq!(compute_count, 2);
709    }
710
711    #[test]
712    fn invalidation_clears_cache() {
713        let mut cache = LayoutCache::new(100);
714        let key = make_key(80, 24);
715
716        let mut compute_count = 0;
717        let mut compute = || {
718            compute_count += 1;
719            vec![]
720        };
721
722        cache.get_or_compute(key, &mut compute);
723        cache.invalidate_all();
724        cache.get_or_compute(key, &mut compute);
725
726        assert_eq!(compute_count, 2);
727    }
728
729    #[test]
730    fn lru_eviction_works() {
731        let mut cache = LayoutCache::new(2);
732
733        let k1 = make_key(10, 10);
734        let k2 = make_key(20, 20);
735        let k3 = make_key(30, 30);
736
737        // Insert two entries
738        cache.get_or_compute(k1, || vec![Rect::new(0, 0, 10, 10)]);
739        cache.get_or_compute(k2, || vec![Rect::new(0, 0, 20, 20)]);
740
741        // Access k1 again (increases access count)
742        cache.get_or_compute(k1, || should_not_call("k1 should hit"));
743
744        // Insert k3, should evict k2 (least accessed)
745        cache.get_or_compute(k3, || vec![Rect::new(0, 0, 30, 30)]);
746
747        assert_eq!(cache.len(), 2);
748
749        // k2 should be evicted
750        let mut was_called = false;
751        cache.get_or_compute(k2, || {
752            was_called = true;
753            vec![]
754        });
755        assert!(was_called, "k2 should have been evicted");
756
757        // k1 should still be cached
758        cache.get_or_compute(k1, || should_not_call("k1 should still be cached"));
759    }
760
761    #[test]
762    fn stats_track_hits_and_misses() {
763        let mut cache = LayoutCache::new(100);
764
765        let k1 = make_key(80, 24);
766        let k2 = make_key(120, 40);
767
768        cache.get_or_compute(k1, Vec::new); // miss
769        cache.get_or_compute(k1, || should_not_call("hit")); // hit
770        cache.get_or_compute(k2, Vec::new); // miss
771
772        let stats = cache.stats();
773        assert_eq!(stats.hits, 1);
774        assert_eq!(stats.misses, 2);
775        assert!((stats.hit_rate - 1.0 / 3.0).abs() < 0.01);
776    }
777
778    #[test]
779    fn reset_stats_clears_counters() {
780        let mut cache = LayoutCache::new(100);
781        let key = make_key(80, 24);
782
783        cache.get_or_compute(key, Vec::new);
784        cache.get_or_compute(key, || should_not_call("hit"));
785
786        let stats = cache.stats();
787        assert_eq!(stats.hits, 1);
788        assert_eq!(stats.misses, 1);
789
790        cache.reset_stats();
791
792        let stats = cache.stats();
793        assert_eq!(stats.hits, 0);
794        assert_eq!(stats.misses, 0);
795        assert_eq!(stats.hit_rate, 0.0);
796    }
797
798    #[test]
799    fn clear_removes_all_entries() {
800        let mut cache = LayoutCache::new(100);
801
802        cache.get_or_compute(make_key(80, 24), Vec::new);
803        cache.get_or_compute(make_key(120, 40), Vec::new);
804
805        assert_eq!(cache.len(), 2);
806
807        cache.clear();
808
809        assert_eq!(cache.len(), 0);
810        assert!(cache.is_empty());
811
812        // All entries should miss now
813        let mut was_called = false;
814        cache.get_or_compute(make_key(80, 24), || {
815            was_called = true;
816            vec![]
817        });
818        assert!(was_called);
819    }
820
821    #[test]
822    fn default_capacity_is_64() {
823        let cache = LayoutCache::default();
824        assert_eq!(cache.capacity(), 64);
825    }
826
827    #[test]
828    fn generation_wraps_around() {
829        let mut cache = LayoutCache::new(100);
830        cache.generation = u64::MAX;
831        cache.invalidate_all();
832        assert_eq!(cache.generation, 0);
833    }
834
835    // --- Constraint hashing tests ---
836
837    #[test]
838    fn constraint_hash_is_stable() {
839        let constraints = [
840            Constraint::Fixed(20),
841            Constraint::Percentage(50.0),
842            Constraint::Min(10),
843        ];
844
845        let h1 = LayoutCacheKey::hash_constraints(&constraints);
846        let h2 = LayoutCacheKey::hash_constraints(&constraints);
847
848        assert_eq!(h1, h2);
849    }
850
851    #[test]
852    fn different_constraint_values_different_hash() {
853        let c1 = [Constraint::Fixed(20)];
854        let c2 = [Constraint::Fixed(30)];
855
856        let h1 = LayoutCacheKey::hash_constraints(&c1);
857        let h2 = LayoutCacheKey::hash_constraints(&c2);
858
859        assert_ne!(h1, h2);
860    }
861
862    #[test]
863    fn different_constraint_types_different_hash() {
864        let c1 = [Constraint::Fixed(20)];
865        let c2 = [Constraint::Min(20)];
866
867        let h1 = LayoutCacheKey::hash_constraints(&c1);
868        let h2 = LayoutCacheKey::hash_constraints(&c2);
869
870        assert_ne!(h1, h2);
871    }
872
873    #[test]
874    fn fit_content_bounded_values_in_hash() {
875        let c1 = [Constraint::FitContentBounded { min: 10, max: 50 }];
876        let c2 = [Constraint::FitContentBounded { min: 10, max: 60 }];
877
878        let h1 = LayoutCacheKey::hash_constraints(&c1);
879        let h2 = LayoutCacheKey::hash_constraints(&c2);
880
881        assert_ne!(h1, h2);
882    }
883
884    // --- Intrinsics hashing tests ---
885
886    #[test]
887    fn intrinsics_hash_is_stable() {
888        let hints = [
889            LayoutSizeHint {
890                min: 10,
891                preferred: 20,
892                max: Some(30),
893            },
894            LayoutSizeHint {
895                min: 5,
896                preferred: 15,
897                max: None,
898            },
899        ];
900
901        let h1 = LayoutCacheKey::hash_intrinsics(&hints);
902        let h2 = LayoutCacheKey::hash_intrinsics(&hints);
903
904        assert_eq!(h1, h2);
905    }
906
907    #[test]
908    fn different_intrinsics_different_hash() {
909        let h1 = [LayoutSizeHint {
910            min: 10,
911            preferred: 20,
912            max: None,
913        }];
914        let h2 = [LayoutSizeHint {
915            min: 10,
916            preferred: 25,
917            max: None,
918        }];
919
920        let hash1 = LayoutCacheKey::hash_intrinsics(&h1);
921        let hash2 = LayoutCacheKey::hash_intrinsics(&h2);
922
923        assert_ne!(hash1, hash2);
924    }
925
926    // --- Property-like tests ---
927
928    #[test]
929    fn cache_is_deterministic() {
930        let mut cache1 = LayoutCache::new(100);
931        let mut cache2 = LayoutCache::new(100);
932
933        for i in 0..10u16 {
934            let key = make_key(i * 10, i * 5);
935            let result = vec![Rect::new(0, 0, i, i)];
936
937            cache1.get_or_compute(key, || result.clone());
938            cache2.get_or_compute(key, || result);
939        }
940
941        assert_eq!(cache1.stats().entries, cache2.stats().entries);
942        assert_eq!(cache1.stats().misses, cache2.stats().misses);
943    }
944
945    #[test]
946    fn hit_count_increments_on_each_access() {
947        let mut cache = LayoutCache::new(100);
948        let key = make_key(80, 24);
949
950        // First access is a miss
951        cache.get_or_compute(key, Vec::new);
952
953        // Subsequent accesses are hits
954        for _ in 0..5 {
955            cache.get_or_compute(key, || should_not_call("should hit"));
956        }
957
958        let stats = cache.stats();
959        assert_eq!(stats.misses, 1);
960        assert_eq!(stats.hits, 5);
961    }
962
963    // -----------------------------------------------------------------------
964    // Coherence Cache Tests (bd-4kq0.4.2)
965    // -----------------------------------------------------------------------
966
967    fn make_coherence_id(n: u16) -> CoherenceId {
968        CoherenceId::new(
969            &[Constraint::Fixed(n), Constraint::Fill],
970            Direction::Horizontal,
971        )
972    }
973
974    #[test]
975    fn coherence_store_and_get() {
976        let mut cc = CoherenceCache::new(64);
977        let id = make_coherence_id(1);
978
979        assert!(cc.get(&id).is_none());
980
981        cc.store(id, vec![30, 50]);
982        assert_eq!(cc.get(&id), Some(vec![30, 50]));
983    }
984
985    #[test]
986    fn coherence_update_replaces_allocation() {
987        let mut cc = CoherenceCache::new(64);
988        let id = make_coherence_id(1);
989
990        cc.store(id, vec![30, 50]);
991        cc.store(id, vec![31, 49]);
992
993        assert_eq!(cc.get(&id), Some(vec![31, 49]));
994        assert_eq!(cc.len(), 1);
995    }
996
997    #[test]
998    fn coherence_different_ids_are_separate() {
999        let mut cc = CoherenceCache::new(64);
1000        let id1 = make_coherence_id(1);
1001        let id2 = make_coherence_id(2);
1002
1003        cc.store(id1, vec![40, 40]);
1004        cc.store(id2, vec![30, 50]);
1005
1006        assert_eq!(cc.get(&id1), Some(vec![40, 40]));
1007        assert_eq!(cc.get(&id2), Some(vec![30, 50]));
1008    }
1009
1010    #[test]
1011    fn coherence_eviction_at_capacity() {
1012        let mut cc = CoherenceCache::new(2);
1013
1014        let id1 = make_coherence_id(1);
1015        let id2 = make_coherence_id(2);
1016        let id3 = make_coherence_id(3);
1017
1018        cc.store(id1, vec![10]);
1019        cc.store(id2, vec![20]);
1020        cc.store(id3, vec![30]);
1021
1022        assert_eq!(cc.len(), 2);
1023        // id1 should be evicted (oldest)
1024        assert!(cc.get(&id1).is_none());
1025        assert_eq!(cc.get(&id2), Some(vec![20]));
1026        assert_eq!(cc.get(&id3), Some(vec![30]));
1027    }
1028
1029    #[test]
1030    fn coherence_clear() {
1031        let mut cc = CoherenceCache::new(64);
1032        let id = make_coherence_id(1);
1033
1034        cc.store(id, vec![10, 20]);
1035        assert_eq!(cc.len(), 1);
1036
1037        cc.clear();
1038        assert!(cc.is_empty());
1039        assert!(cc.get(&id).is_none());
1040    }
1041
1042    #[test]
1043    fn coherence_displacement_with_previous() {
1044        let mut cc = CoherenceCache::new(64);
1045        let id = make_coherence_id(1);
1046
1047        cc.store(id, vec![30, 50]);
1048
1049        // New allocation differs by 2 cells in each slot
1050        let (sum, max) = cc.displacement(&id, &[32, 48]);
1051        assert_eq!(sum, 4); // |32-30| + |48-50| = 2 + 2
1052        assert_eq!(max, 2);
1053    }
1054
1055    #[test]
1056    fn coherence_displacement_without_previous() {
1057        let cc = CoherenceCache::new(64);
1058        let id = make_coherence_id(1);
1059
1060        let (sum, max) = cc.displacement(&id, &[30, 50]);
1061        assert_eq!(sum, 0);
1062        assert_eq!(max, 0);
1063    }
1064
1065    #[test]
1066    fn coherence_displacement_different_lengths() {
1067        let mut cc = CoherenceCache::new(64);
1068        let id = make_coherence_id(1);
1069
1070        cc.store(id, vec![30, 50]);
1071
1072        // New allocation has 3 elements (extra element counted as displacement)
1073        let (sum, max) = cc.displacement(&id, &[30, 50, 10]);
1074        assert_eq!(sum, 10);
1075        assert_eq!(max, 10);
1076    }
1077
1078    #[test]
1079    fn coherence_from_cache_key() {
1080        let key = make_key(80, 24);
1081        let id = CoherenceId::from_cache_key(&key);
1082
1083        // Same constraints + direction should produce same ID regardless of area
1084        let key2 = make_key(120, 40);
1085        let id2 = CoherenceId::from_cache_key(&key2);
1086
1087        assert_eq!(id, id2);
1088    }
1089
1090    // --- unit_cache_reuse ---
1091
1092    #[test]
1093    fn unit_cache_reuse_unchanged_constraints_yield_identical_layout() {
1094        use crate::round_layout_stable;
1095
1096        let mut cc = CoherenceCache::new(64);
1097        let id = make_coherence_id(1);
1098
1099        // First layout at width 80
1100        let targets = [26.67, 26.67, 26.66];
1101        let total = 80;
1102        let alloc1 = round_layout_stable(&targets, total, cc.get(&id));
1103        cc.store(id, alloc1.clone());
1104
1105        // Same constraints, same width → should produce identical result
1106        let alloc2 = round_layout_stable(&targets, total, cc.get(&id));
1107        assert_eq!(alloc1, alloc2, "Same inputs should yield identical layout");
1108    }
1109
1110    // --- e2e_resize_sweep ---
1111
1112    #[test]
1113    fn e2e_resize_sweep_bounded_displacement() {
1114        use crate::round_layout_stable;
1115
1116        let mut cc = CoherenceCache::new(64);
1117        let id = make_coherence_id(1);
1118
1119        // Equal three-way split: targets are total/3 each
1120        // Sweep terminal width from 60 to 120 in steps of 1
1121        let mut max_displacement_ever: u32 = 0;
1122        let mut total_displacement_sum: u64 = 0;
1123        let steps = 61; // 60..=120
1124
1125        for width in 60u16..=120 {
1126            let third = f64::from(width) / 3.0;
1127            let targets = [third, third, third];
1128
1129            let prev = cc.get(&id);
1130            let alloc = round_layout_stable(&targets, width, prev);
1131
1132            let (d_sum, d_max) = cc.displacement(&id, &alloc);
1133            total_displacement_sum += d_sum;
1134            max_displacement_ever = max_displacement_ever.max(d_max);
1135
1136            cc.store(id, alloc);
1137        }
1138
1139        // Each 1-cell width change should cause at most 1 cell of displacement
1140        // in each slot (ideal: only 1 slot changes by 1).
1141        assert!(
1142            max_displacement_ever <= 2,
1143            "Max single-step displacement should be <=2 cells, got {}",
1144            max_displacement_ever
1145        );
1146
1147        // Average displacement per step should be ~1 (one extra cell redistributed)
1148        let avg = total_displacement_sum as f64 / steps as f64;
1149        assert!(
1150            avg < 3.0,
1151            "Average displacement per step should be <3 cells, got {:.2}",
1152            avg
1153        );
1154    }
1155
1156    #[test]
1157    fn e2e_resize_sweep_deterministic() {
1158        use crate::round_layout_stable;
1159
1160        // Two identical sweeps should produce identical displacement logs
1161        let sweep = |seed: u16| -> Vec<(u16, Vec<u16>, u64, u32)> {
1162            let mut cc = CoherenceCache::new(64);
1163            let id = CoherenceId::new(
1164                &[Constraint::Percentage(30.0), Constraint::Fill],
1165                Direction::Horizontal,
1166            );
1167
1168            let mut log = Vec::new();
1169            for width in (40 + seed)..(100 + seed) {
1170                let targets = [f64::from(width) * 0.3, f64::from(width) * 0.7];
1171                let prev = cc.get(&id);
1172                let alloc = round_layout_stable(&targets, width, prev);
1173                let (d_sum, d_max) = cc.displacement(&id, &alloc);
1174                cc.store(id, alloc.clone());
1175                log.push((width, alloc, d_sum, d_max));
1176            }
1177            log
1178        };
1179
1180        let log1 = sweep(0);
1181        let log2 = sweep(0);
1182        assert_eq!(log1, log2, "Identical sweeps should produce identical logs");
1183    }
1184
1185    #[test]
1186    fn default_coherence_cache_capacity_is_64() {
1187        let cc = CoherenceCache::default();
1188        assert_eq!(cc.max_entries, 64);
1189    }
1190}