Skip to main content

ftui_runtime/tick_strategy/
transition_history.rs

1//! Fixed-capacity ring buffer for ordered screen transition sequences.
2//!
3//! Supplements [`TransitionCounter`](super::TransitionCounter) which stores
4//! aggregate counts. The history buffer preserves the raw ordered sequence of
5//! the last N transitions, enabling:
6//!
7//! - Debug introspection ("show me the last 20 screen switches")
8//! - Higher-order Markov analysis (bigram/trigram patterns)
9//! - Session analytics (time between transitions)
10
11use std::collections::VecDeque;
12
13/// Default capacity if none is specified.
14const DEFAULT_CAPACITY: usize = 256;
15
16/// A single recorded screen transition.
17#[derive(Debug, Clone)]
18pub struct TransitionEntry<S: Clone> {
19    /// Screen the user was on before the transition.
20    pub from: S,
21    /// Screen the user switched to.
22    pub to: S,
23    /// Monotonic timestamp of the transition.
24    pub timestamp: web_time::Instant,
25    /// The runtime tick count at the time of the transition.
26    pub tick_count: u64,
27}
28
29/// Fixed-capacity ring buffer of screen transitions.
30///
31/// When full, the oldest entry is evicted on each new `record()`.
32///
33/// # Example
34///
35/// ```
36/// use ftui_runtime::tick_strategy::TransitionHistory;
37///
38/// let mut history = TransitionHistory::with_capacity(100);
39/// history.record("Dashboard".to_string(), "Messages".to_string(), 42);
40/// assert_eq!(history.len(), 1);
41/// ```
42#[derive(Debug, Clone)]
43pub struct TransitionHistory<S: Clone> {
44    entries: VecDeque<TransitionEntry<S>>,
45    capacity: usize,
46}
47
48impl<S: Clone> TransitionHistory<S> {
49    /// Create a history buffer with the given capacity.
50    ///
51    /// A capacity of 0 is clamped to 1.
52    #[must_use]
53    pub fn with_capacity(capacity: usize) -> Self {
54        let capacity = capacity.max(1);
55        Self {
56            entries: VecDeque::with_capacity(capacity),
57            capacity,
58        }
59    }
60
61    /// Record a new screen transition.
62    ///
63    /// If the buffer is at capacity, the oldest entry is evicted.
64    pub fn record(&mut self, from: S, to: S, tick_count: u64) {
65        if self.entries.len() == self.capacity {
66            self.entries.pop_front();
67        }
68        self.entries.push_back(TransitionEntry {
69            from,
70            to,
71            timestamp: web_time::Instant::now(),
72            tick_count,
73        });
74    }
75
76    /// Return the most recent `n` transitions (or fewer if less are stored).
77    ///
78    /// Entries are ordered oldest-first.
79    #[must_use]
80    pub fn recent(&self, n: usize) -> Vec<&TransitionEntry<S>> {
81        let start = self.entries.len().saturating_sub(n);
82        self.entries.range(start..).collect()
83    }
84
85    /// Return the last `n` distinct destination screens visited, most recent last.
86    #[must_use]
87    pub fn last_n_screens(&self, n: usize) -> Vec<&S>
88    where
89        S: PartialEq,
90    {
91        let mut screens = Vec::new();
92        for entry in self.entries.iter().rev() {
93            if screens.len() >= n {
94                break;
95            }
96            if !screens.contains(&&entry.to) {
97                screens.push(&entry.to);
98            }
99        }
100        screens.reverse();
101        screens
102    }
103
104    /// Number of transitions currently stored.
105    #[must_use]
106    pub fn len(&self) -> usize {
107        self.entries.len()
108    }
109
110    /// Whether the buffer is empty.
111    #[must_use]
112    pub fn is_empty(&self) -> bool {
113        self.entries.is_empty()
114    }
115
116    /// The configured capacity.
117    #[must_use]
118    pub fn capacity(&self) -> usize {
119        self.capacity
120    }
121
122    /// Clear all stored transitions.
123    pub fn clear(&mut self) {
124        self.entries.clear();
125    }
126}
127
128impl<S: Clone> Default for TransitionHistory<S> {
129    fn default() -> Self {
130        Self::with_capacity(DEFAULT_CAPACITY)
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn empty_history() {
140        let h = TransitionHistory::<String>::with_capacity(10);
141        assert!(h.is_empty());
142        assert_eq!(h.len(), 0);
143        assert_eq!(h.capacity(), 10);
144        assert!(h.recent(5).is_empty());
145    }
146
147    #[test]
148    fn record_and_retrieve() {
149        let mut h = TransitionHistory::with_capacity(10);
150        h.record("A".to_owned(), "B".to_owned(), 1);
151        h.record("B".to_owned(), "C".to_owned(), 2);
152
153        assert_eq!(h.len(), 2);
154        assert!(!h.is_empty());
155
156        let recent = h.recent(10);
157        assert_eq!(recent.len(), 2);
158        assert_eq!(recent[0].from, "A");
159        assert_eq!(recent[0].to, "B");
160        assert_eq!(recent[0].tick_count, 1);
161        assert_eq!(recent[1].from, "B");
162        assert_eq!(recent[1].to, "C");
163    }
164
165    #[test]
166    fn eviction_at_capacity() {
167        let mut h = TransitionHistory::with_capacity(3);
168        h.record("A".to_owned(), "B".to_owned(), 1);
169        h.record("B".to_owned(), "C".to_owned(), 2);
170        h.record("C".to_owned(), "D".to_owned(), 3);
171        assert_eq!(h.len(), 3);
172
173        // This should evict the oldest (A→B).
174        h.record("D".to_owned(), "E".to_owned(), 4);
175        assert_eq!(h.len(), 3);
176
177        let recent = h.recent(10);
178        assert_eq!(recent[0].from, "B");
179        assert_eq!(recent[0].to, "C");
180        assert_eq!(recent[2].from, "D");
181        assert_eq!(recent[2].to, "E");
182    }
183
184    #[test]
185    fn recent_returns_tail() {
186        let mut h = TransitionHistory::with_capacity(10);
187        for i in 0..5 {
188            h.record(format!("s{i}"), format!("s{}", i + 1), i as u64);
189        }
190
191        let last2 = h.recent(2);
192        assert_eq!(last2.len(), 2);
193        assert_eq!(last2[0].from, "s3");
194        assert_eq!(last2[1].from, "s4");
195    }
196
197    #[test]
198    fn recent_more_than_stored() {
199        let mut h = TransitionHistory::with_capacity(10);
200        h.record("A".to_owned(), "B".to_owned(), 0);
201
202        let all = h.recent(100);
203        assert_eq!(all.len(), 1);
204    }
205
206    #[test]
207    fn last_n_screens_deduplicates() {
208        let mut h = TransitionHistory::with_capacity(10);
209        h.record("A".to_owned(), "B".to_owned(), 1);
210        h.record("B".to_owned(), "A".to_owned(), 2);
211        h.record("A".to_owned(), "B".to_owned(), 3);
212        h.record("B".to_owned(), "C".to_owned(), 4);
213
214        // Last 3 unique destinations: B, A, C → but in visit order: A, B, C
215        let screens = h.last_n_screens(3);
216        assert_eq!(screens.len(), 3);
217        assert_eq!(*screens[0], "A");
218        assert_eq!(*screens[1], "B");
219        assert_eq!(*screens[2], "C");
220    }
221
222    #[test]
223    fn last_n_screens_fewer_than_requested() {
224        let mut h = TransitionHistory::with_capacity(10);
225        h.record("A".to_owned(), "B".to_owned(), 1);
226
227        let screens = h.last_n_screens(5);
228        assert_eq!(screens.len(), 1);
229        assert_eq!(*screens[0], "B");
230    }
231
232    #[test]
233    fn capacity_zero_clamped_to_one() {
234        let mut h = TransitionHistory::with_capacity(0);
235        assert_eq!(h.capacity(), 1);
236        h.record("A".to_owned(), "B".to_owned(), 0);
237        assert_eq!(h.len(), 1);
238        h.record("B".to_owned(), "C".to_owned(), 1);
239        assert_eq!(h.len(), 1);
240        assert_eq!(h.recent(1)[0].to, "C");
241    }
242
243    #[test]
244    fn default_capacity() {
245        let h = TransitionHistory::<String>::default();
246        assert_eq!(h.capacity(), 256);
247    }
248
249    #[test]
250    fn clear_empties_buffer() {
251        let mut h = TransitionHistory::with_capacity(10);
252        h.record("A".to_owned(), "B".to_owned(), 0);
253        h.record("B".to_owned(), "C".to_owned(), 1);
254        assert_eq!(h.len(), 2);
255
256        h.clear();
257        assert!(h.is_empty());
258        assert_eq!(h.len(), 0);
259    }
260
261    #[test]
262    fn timestamps_are_monotonic() {
263        let mut h = TransitionHistory::with_capacity(10);
264        h.record("A".to_owned(), "B".to_owned(), 0);
265        h.record("B".to_owned(), "C".to_owned(), 1);
266
267        let entries = h.recent(2);
268        assert!(entries[1].timestamp >= entries[0].timestamp);
269    }
270
271    #[test]
272    fn clone_is_independent() {
273        let mut h = TransitionHistory::with_capacity(5);
274        h.record("A".to_owned(), "B".to_owned(), 0);
275
276        let mut clone = h.clone();
277        clone.record("B".to_owned(), "C".to_owned(), 1);
278
279        assert_eq!(h.len(), 1);
280        assert_eq!(clone.len(), 2);
281    }
282}