1mod active_only;
14mod active_plus_adjacent;
15mod custom;
16mod markov_predictor;
17#[cfg(any(feature = "state-persistence", test))]
18pub mod persistence;
19mod predictive;
20mod tick_allocation;
21mod transition_counter;
22mod transition_history;
23mod uniform;
24
25pub use active_only::ActiveOnly;
26pub use active_plus_adjacent::ActivePlusAdjacent;
27pub use custom::Custom;
28pub use markov_predictor::{DecayConfig, MarkovPredictor, ScreenPrediction};
29#[cfg(feature = "state-persistence")]
30pub use persistence::{load_transitions, save_transitions};
31pub use predictive::{Predictive, PredictiveStrategyConfig};
33pub use tick_allocation::{AllocationCurve, TickAllocation};
34pub use transition_counter::TransitionCounter;
35pub use transition_history::{TransitionEntry, TransitionHistory};
36pub use uniform::Uniform;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum TickDecision {
41 Tick,
43 Skip,
45}
46
47pub trait TickStrategy: Send {
53 fn should_tick(
55 &mut self,
56 screen_id: &str,
57 tick_count: u64,
58 active_screen: &str,
59 ) -> TickDecision;
60
61 fn on_screen_transition(&mut self, _from: &str, _to: &str) {}
63
64 fn maintenance_tick(&mut self, _tick_count: u64) {}
66
67 fn shutdown(&mut self) {}
69
70 fn name(&self) -> &str;
72
73 fn debug_stats(&self) -> Vec<(String, String)> {
75 Vec::new()
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct PredictiveConfig {
84 pub fallback_divisor: u64,
86}
87
88impl PredictiveConfig {
89 #[must_use]
91 pub const fn new(fallback_divisor: u64) -> Self {
92 Self { fallback_divisor }
93 }
94
95 #[must_use]
96 const fn normalized_fallback_divisor(self) -> u64 {
97 if self.fallback_divisor == 0 {
98 1
99 } else {
100 self.fallback_divisor
101 }
102 }
103}
104
105impl Default for PredictiveConfig {
106 fn default() -> Self {
107 Self {
108 fallback_divisor: 5,
109 }
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum TickStrategyKind {
116 ActiveOnly,
118 Uniform { divisor: u64 },
120 ActivePlusAdjacent {
122 screens: Vec<String>,
124 background_divisor: u64,
126 },
127 Predictive { config: PredictiveConfig },
129}
130
131impl TickStrategyKind {
132 #[must_use]
133 const fn normalized_divisor(divisor: u64) -> u64 {
134 if divisor == 0 { 1 } else { divisor }
135 }
136}
137
138impl TickStrategy for TickStrategyKind {
139 fn should_tick(
140 &mut self,
141 screen_id: &str,
142 tick_count: u64,
143 _active_screen: &str,
144 ) -> TickDecision {
145 match self {
146 Self::ActiveOnly => TickDecision::Skip,
147 Self::Uniform { divisor } => {
148 if tick_count.is_multiple_of(Self::normalized_divisor(*divisor)) {
149 TickDecision::Tick
150 } else {
151 TickDecision::Skip
152 }
153 }
154 Self::ActivePlusAdjacent {
155 screens,
156 background_divisor,
157 } => {
158 if screens.iter().any(|adjacent| adjacent == screen_id)
159 || tick_count.is_multiple_of(Self::normalized_divisor(*background_divisor))
160 {
161 TickDecision::Tick
162 } else {
163 TickDecision::Skip
164 }
165 }
166 Self::Predictive { config } => {
167 if tick_count.is_multiple_of(config.normalized_fallback_divisor()) {
168 TickDecision::Tick
169 } else {
170 TickDecision::Skip
171 }
172 }
173 }
174 }
175
176 fn name(&self) -> &str {
177 match self {
178 Self::ActiveOnly => "ActiveOnly",
179 Self::Uniform { .. } => "Uniform",
180 Self::ActivePlusAdjacent { .. } => "ActivePlusAdjacent",
181 Self::Predictive { .. } => "Predictive",
182 }
183 }
184
185 fn debug_stats(&self) -> Vec<(String, String)> {
186 match self {
187 Self::ActiveOnly => vec![("strategy".into(), "ActiveOnly".into())],
188 Self::Uniform { divisor } => vec![
189 ("strategy".into(), "Uniform".into()),
190 (
191 "divisor".into(),
192 Self::normalized_divisor(*divisor).to_string(),
193 ),
194 ],
195 Self::ActivePlusAdjacent {
196 screens,
197 background_divisor,
198 } => vec![
199 ("strategy".into(), "ActivePlusAdjacent".into()),
200 (
201 "background_divisor".into(),
202 Self::normalized_divisor(*background_divisor).to_string(),
203 ),
204 ("adjacent_screen_count".into(), screens.len().to_string()),
205 ],
206 Self::Predictive { config } => vec![
207 ("strategy".into(), "Predictive".into()),
208 (
209 "fallback_divisor".into(),
210 config.normalized_fallback_divisor().to_string(),
211 ),
212 ],
213 }
214 }
215}
216
217pub trait ScreenTickDispatch {
225 fn screen_ids(&self) -> Vec<String>;
227
228 fn active_screen_id(&self) -> String;
230
231 fn tick_screen(&mut self, screen_id: &str, tick_count: u64);
236}
237
238#[cfg(test)]
239mod tests {
240 use super::{PredictiveConfig, TickDecision, TickStrategy, TickStrategyKind};
241
242 struct NoopStrategy;
243
244 impl TickStrategy for NoopStrategy {
245 fn should_tick(
246 &mut self,
247 _screen_id: &str,
248 _tick_count: u64,
249 _active_screen: &str,
250 ) -> TickDecision {
251 TickDecision::Skip
252 }
253
254 fn name(&self) -> &str {
255 "Noop"
256 }
257 }
258
259 #[test]
260 fn tick_decision_copy_and_eq() {
261 let decision = TickDecision::Tick;
262 let copied = decision;
263 assert_eq!(copied, TickDecision::Tick);
264 assert_ne!(TickDecision::Tick, TickDecision::Skip);
265 assert!(format!("{decision:?}").contains("Tick"));
266 }
267
268 #[test]
269 fn default_trait_hooks_are_noops() {
270 let mut strategy = NoopStrategy;
271 strategy.on_screen_transition("A", "B");
272 strategy.maintenance_tick(123);
273 strategy.shutdown();
274 assert!(strategy.debug_stats().is_empty());
275 }
276
277 #[test]
278 fn tick_strategy_kind_delegates_should_tick() {
279 let mut active_only = TickStrategyKind::ActiveOnly;
280 assert_eq!(
281 active_only.should_tick("ScreenA", 10, "ScreenB"),
282 TickDecision::Skip
283 );
284
285 let mut uniform = TickStrategyKind::Uniform { divisor: 5 };
286 assert_eq!(
287 uniform.should_tick("ScreenA", 10, "ScreenB"),
288 TickDecision::Tick
289 );
290 assert_eq!(
291 uniform.should_tick("ScreenA", 11, "ScreenB"),
292 TickDecision::Skip
293 );
294
295 let mut uniform_zero = TickStrategyKind::Uniform { divisor: 0 };
296 assert_eq!(
297 uniform_zero.should_tick("ScreenA", 3, "ScreenB"),
298 TickDecision::Tick
299 );
300
301 let mut active_plus_adjacent = TickStrategyKind::ActivePlusAdjacent {
302 screens: vec!["Messages".into(), "Threads".into()],
303 background_divisor: 4,
304 };
305 assert_eq!(
306 active_plus_adjacent.should_tick("Messages", 1, "Dashboard"),
307 TickDecision::Tick
308 );
309 assert_eq!(
310 active_plus_adjacent.should_tick("Settings", 4, "Dashboard"),
311 TickDecision::Tick
312 );
313 assert_eq!(
314 active_plus_adjacent.should_tick("Settings", 5, "Dashboard"),
315 TickDecision::Skip
316 );
317
318 let mut predictive = TickStrategyKind::Predictive {
319 config: PredictiveConfig::new(3),
320 };
321 assert_eq!(
322 predictive.should_tick("ScreenA", 6, "ScreenB"),
323 TickDecision::Tick
324 );
325 assert_eq!(
326 predictive.should_tick("ScreenA", 7, "ScreenB"),
327 TickDecision::Skip
328 );
329 }
330
331 #[test]
332 fn tick_strategy_kind_names_are_stable() {
333 assert_eq!(TickStrategyKind::ActiveOnly.name(), "ActiveOnly");
334 assert_eq!(TickStrategyKind::Uniform { divisor: 5 }.name(), "Uniform");
335 assert_eq!(
336 TickStrategyKind::ActivePlusAdjacent {
337 screens: vec![],
338 background_divisor: 5,
339 }
340 .name(),
341 "ActivePlusAdjacent"
342 );
343 assert_eq!(
344 TickStrategyKind::Predictive {
345 config: PredictiveConfig::default(),
346 }
347 .name(),
348 "Predictive"
349 );
350 }
351
352 #[test]
353 fn predictive_default_config_matches_design() {
354 assert_eq!(PredictiveConfig::default().fallback_divisor, 5);
355 }
356
357 use super::ScreenTickDispatch;
362
363 struct MockMultiScreen {
364 active: String,
365 screens: Vec<String>,
366 ticked: Vec<(String, u64)>,
367 }
368
369 impl MockMultiScreen {
370 fn new(active: &str, screens: &[&str]) -> Self {
371 Self {
372 active: active.to_owned(),
373 screens: screens.iter().map(|s| (*s).to_owned()).collect(),
374 ticked: Vec::new(),
375 }
376 }
377 }
378
379 impl ScreenTickDispatch for MockMultiScreen {
380 fn screen_ids(&self) -> Vec<String> {
381 self.screens.clone()
382 }
383
384 fn active_screen_id(&self) -> String {
385 self.active.clone()
386 }
387
388 fn tick_screen(&mut self, screen_id: &str, tick_count: u64) {
389 self.ticked.push((screen_id.to_owned(), tick_count));
390 }
391 }
392
393 #[test]
394 fn screen_tick_dispatch_returns_all_screens() {
395 let mock = MockMultiScreen::new("A", &["A", "B", "C"]);
396 assert_eq!(mock.screen_ids(), vec!["A", "B", "C"]);
397 }
398
399 #[test]
400 fn screen_tick_dispatch_reports_active() {
401 let mock = MockMultiScreen::new("B", &["A", "B", "C"]);
402 assert_eq!(mock.active_screen_id(), "B");
403 }
404
405 #[test]
406 fn screen_tick_dispatch_records_ticks() {
407 let mut mock = MockMultiScreen::new("A", &["A", "B", "C"]);
408 mock.tick_screen("B", 5);
409 mock.tick_screen("C", 5);
410 assert_eq!(mock.ticked.len(), 2);
411 assert_eq!(mock.ticked[0], ("B".to_owned(), 5));
412 assert_eq!(mock.ticked[1], ("C".to_owned(), 5));
413 }
414
415 #[test]
416 fn screen_tick_dispatch_unknown_id_is_noop() {
417 let mut mock = MockMultiScreen::new("A", &["A", "B"]);
418 mock.tick_screen("UNKNOWN", 10);
419 assert_eq!(mock.ticked.len(), 1);
422 }
423
424 use super::{
429 ActiveOnly, ActivePlusAdjacent, Custom, Predictive, PredictiveStrategyConfig, Uniform,
430 };
431
432 #[test]
435 fn all_strategies_implement_send() {
436 fn assert_send<T: Send>() {}
437
438 assert_send::<ActiveOnly>();
439 assert_send::<Uniform>();
440 assert_send::<ActivePlusAdjacent>();
441 assert_send::<Predictive>();
442 assert_send::<Custom>();
443 assert_send::<TickStrategyKind>();
444 }
445
446 #[test]
448 fn all_strategies_boxable_as_dyn_tick_strategy() {
449 let strategies: Vec<Box<dyn TickStrategy>> = vec![
450 Box::new(ActiveOnly),
451 Box::new(Uniform::new(5)),
452 Box::new(ActivePlusAdjacent::new(5)),
453 Box::new(Predictive::new(PredictiveStrategyConfig::default())),
454 Box::new(Custom::new("test", |_, _, _| TickDecision::Skip)),
455 Box::new(TickStrategyKind::ActiveOnly),
456 ];
457
458 for mut s in strategies {
459 let _ = s.should_tick("screen", 0, "active");
461 assert!(!s.name().is_empty());
463 }
464 }
465
466 #[test]
469 fn lifecycle_hooks_are_safe_for_all_strategies() {
470 let mut strategies: Vec<Box<dyn TickStrategy>> = vec![
471 Box::new(ActiveOnly),
472 Box::new(Uniform::new(5)),
473 Box::new(ActivePlusAdjacent::new(5)),
474 Box::new(Custom::new("test", |_, _, _| TickDecision::Skip)),
475 Box::new(TickStrategyKind::Uniform { divisor: 5 }),
476 ];
477
478 for s in &mut strategies {
479 s.on_screen_transition("A", "B");
480 s.maintenance_tick(100);
481 s.shutdown();
482 }
484 }
485}