use std::collections::HashMap;
use ftui_runtime::{
ActiveOnly, Predictive, PredictiveStrategyConfig, ScreenTickDispatch, TickDecision,
TickStrategy, TickStrategyKind, Uniform,
};
struct MockMultiScreenModel {
screens: Vec<String>,
active: usize,
tick_counts: HashMap<String, u64>,
total_ticks: u64,
}
impl MockMultiScreenModel {
fn new(screen_names: &[&str]) -> Self {
Self {
screens: screen_names.iter().map(|s| s.to_string()).collect(),
active: 0,
tick_counts: HashMap::new(),
total_ticks: 0,
}
}
fn switch_to(&mut self, screen_name: &str) {
if let Some(idx) = self.screens.iter().position(|s| s == screen_name) {
self.active = idx;
}
}
fn log_distribution(&self, label: &str) {
eprintln!("=== Tick distribution: {label} ===");
let mut entries: Vec<_> = self.tick_counts.iter().collect();
entries.sort_by_key(|(name, _)| (*name).clone());
for (screen, count) in entries {
let pct = if self.total_ticks > 0 {
*count as f64 / self.total_ticks as f64 * 100.0
} else {
0.0
};
eprintln!(" {screen}: {count} ticks ({pct:.1}%)");
}
eprintln!(" total: {} ticks", self.total_ticks);
}
}
impl ScreenTickDispatch for MockMultiScreenModel {
fn screen_ids(&self) -> Vec<String> {
self.screens.clone()
}
fn active_screen_id(&self) -> String {
self.screens[self.active].clone()
}
fn tick_screen(&mut self, screen_id: &str, _tick_count: u64) {
*self.tick_counts.entry(screen_id.to_string()).or_default() += 1;
self.total_ticks += 1;
}
}
fn simulate_frames(
model: &mut MockMultiScreenModel,
strategy: &mut dyn TickStrategy,
frames: u64,
start_tick: u64,
) {
for tick in start_tick..start_tick + frames {
let active = model.active_screen_id();
let all_screens = model.screen_ids();
model.tick_screen(&active, tick);
for screen_id in &all_screens {
if *screen_id != active
&& strategy.should_tick(screen_id, tick, &active) == TickDecision::Tick
{
model.tick_screen(screen_id, tick);
}
}
strategy.maintenance_tick(tick);
}
}
#[test]
fn baseline_no_strategy_all_screens_tick_every_frame() {
let mut model = MockMultiScreenModel::new(&["A", "B", "C", "D"]);
let frames = 100;
for tick in 0..frames {
for screen in &model.screens.clone() {
model.tick_screen(screen, tick);
}
}
model.log_distribution("baseline (no strategy)");
for screen in &model.screens {
assert_eq!(
model.tick_counts.get(screen).copied().unwrap_or(0),
frames,
"screen {screen} should have {frames} ticks"
);
}
assert_eq!(model.total_ticks, frames * 4);
}
#[test]
fn active_only_strategy_ticks_only_active() {
let mut model = MockMultiScreenModel::new(&["A", "B", "C", "D"]);
let mut strategy = ActiveOnly;
let frames = 100;
simulate_frames(&mut model, &mut strategy, frames, 0);
model.log_distribution("ActiveOnly");
assert_eq!(model.tick_counts.get("A").copied().unwrap_or(0), frames);
assert_eq!(model.tick_counts.get("B").copied().unwrap_or(0), 0);
assert_eq!(model.tick_counts.get("C").copied().unwrap_or(0), 0);
assert_eq!(model.tick_counts.get("D").copied().unwrap_or(0), 0);
assert_eq!(model.total_ticks, frames);
}
#[test]
fn uniform_strategy_ticks_inactive_at_divisor_rate() {
let mut model = MockMultiScreenModel::new(&["A", "B", "C"]);
let mut strategy = Uniform::new(5);
let frames = 100;
simulate_frames(&mut model, &mut strategy, frames, 0);
model.log_distribution("Uniform(5)");
assert_eq!(model.tick_counts.get("A").copied().unwrap_or(0), frames);
let expected_inactive = frames / 5;
assert_eq!(
model.tick_counts.get("B").copied().unwrap_or(0),
expected_inactive
);
assert_eq!(
model.tick_counts.get("C").copied().unwrap_or(0),
expected_inactive
);
}
#[test]
fn predictive_cold_start_uses_fallback_divisor() {
let mut model = MockMultiScreenModel::new(&["A", "B", "C"]);
let config = PredictiveStrategyConfig {
fallback_divisor: 10,
min_observations: 50, ..PredictiveStrategyConfig::default()
};
let mut strategy = Predictive::new(config);
let frames = 100;
simulate_frames(&mut model, &mut strategy, frames, 0);
model.log_distribution("Predictive (cold start, fallback=10)");
assert_eq!(model.tick_counts.get("A").copied().unwrap_or(0), frames);
let expected_inactive = frames / 10;
assert_eq!(
model.tick_counts.get("B").copied().unwrap_or(0),
expected_inactive
);
assert_eq!(
model.tick_counts.get("C").copied().unwrap_or(0),
expected_inactive
);
}
#[test]
fn predictive_warm_favors_likely_targets() {
let mut model = MockMultiScreenModel::new(&["A", "B", "C", "D"]);
let config = PredictiveStrategyConfig {
min_observations: 5,
fallback_divisor: 20,
decay_interval: 10_000, ..PredictiveStrategyConfig::default()
};
let mut strategy = Predictive::new(config);
for _ in 0..80 {
strategy.on_screen_transition("A", "B");
}
for _ in 0..15 {
strategy.on_screen_transition("A", "C");
}
for _ in 0..5 {
strategy.on_screen_transition("A", "D");
}
let frames = 200;
simulate_frames(&mut model, &mut strategy, frames, 0);
model.log_distribution("Predictive (warm, A active)");
let b_ticks = model.tick_counts.get("B").copied().unwrap_or(0);
let c_ticks = model.tick_counts.get("C").copied().unwrap_or(0);
let d_ticks = model.tick_counts.get("D").copied().unwrap_or(0);
assert!(
b_ticks > c_ticks,
"B ({b_ticks}) should tick more than C ({c_ticks})"
);
assert!(
b_ticks > d_ticks,
"B ({b_ticks}) should tick more than D ({d_ticks})"
);
assert!(
c_ticks >= d_ticks,
"C ({c_ticks}) should tick >= D ({d_ticks})"
);
}
#[test]
fn screen_switch_changes_active_ticking() {
let mut model = MockMultiScreenModel::new(&["A", "B", "C"]);
let mut strategy = ActiveOnly;
simulate_frames(&mut model, &mut strategy, 50, 0);
let a_ticks_before = model.tick_counts.get("A").copied().unwrap_or(0);
assert_eq!(a_ticks_before, 50);
assert_eq!(model.tick_counts.get("B").copied().unwrap_or(0), 0);
model.switch_to("B");
model.tick_screen("B", 50);
simulate_frames(&mut model, &mut strategy, 50, 50);
model.log_distribution("screen switch A→B");
assert_eq!(model.tick_counts.get("A").copied().unwrap_or(0), 50);
assert_eq!(model.tick_counts.get("B").copied().unwrap_or(0), 51);
}
#[test]
fn screen_switch_updates_predictions() {
let config = PredictiveStrategyConfig {
min_observations: 5,
fallback_divisor: 20,
decay_interval: 10_000,
..PredictiveStrategyConfig::default()
};
let mut strategy = Predictive::new(config);
for _ in 0..50 {
strategy.on_screen_transition("A", "B");
}
let predictions = strategy.predictor().predict(&"A".to_string());
let b_pred = predictions.iter().find(|p| p.screen == "B");
assert!(b_pred.is_some(), "B should be in predictions from A");
let b_prob = b_pred.unwrap().probability;
assert!(
b_prob > 0.8,
"B probability from A should be >0.8, got {b_prob}"
);
let b_decision_tick0 = strategy.should_tick("B", 0, "A");
assert_eq!(
b_decision_tick0,
TickDecision::Tick,
"B should tick on frame 0 when A is active (high probability target)"
);
}
#[cfg(feature = "state-persistence")]
#[test]
fn persistence_round_trip_preserves_predictions() {
use ftui_runtime::{load_transitions, save_transitions};
let config = PredictiveStrategyConfig {
min_observations: 5,
fallback_divisor: 20,
decay_interval: 10_000,
..PredictiveStrategyConfig::default()
};
let mut original = Predictive::new(config.clone());
for _ in 0..60 {
original.on_screen_transition("A", "B");
}
for _ in 0..30 {
original.on_screen_transition("A", "C");
}
for _ in 0..10 {
original.on_screen_transition("A", "D");
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("transitions.json");
save_transitions(original.counter(), &path).unwrap();
let loaded_counter = load_transitions(&path).unwrap();
let mut restored = Predictive::with_counter(config, loaded_counter);
let orig_preds = original.predictor().predict(&"A".to_string());
let restored_preds = restored.predictor().predict(&"A".to_string());
assert_eq!(
orig_preds.len(),
restored_preds.len(),
"prediction count should match"
);
for (o, r) in orig_preds.iter().zip(restored_preds.iter()) {
assert_eq!(o.screen, r.screen, "screen names should match");
assert!(
(o.probability - r.probability).abs() < 1e-9,
"probabilities should match for {}: {} vs {}",
o.screen,
o.probability,
r.probability
);
}
let mut model_orig = MockMultiScreenModel::new(&["A", "B", "C", "D"]);
let mut model_restored = MockMultiScreenModel::new(&["A", "B", "C", "D"]);
let frames = 100;
simulate_frames(&mut model_orig, &mut original, frames, 0);
simulate_frames(&mut model_restored, &mut restored, frames, 0);
model_orig.log_distribution("original");
model_restored.log_distribution("restored from disk");
for screen in &["A", "B", "C", "D"] {
let o = model_orig.tick_counts.get(*screen).copied().unwrap_or(0);
let r = model_restored
.tick_counts
.get(*screen)
.copied()
.unwrap_or(0);
assert_eq!(o, r, "tick count mismatch for screen {screen}: {o} vs {r}");
}
}
#[test]
fn tick_strategy_kind_delegates_full_lifecycle() {
let mut model = MockMultiScreenModel::new(&["A", "B", "C"]);
let mut strategy = TickStrategyKind::Uniform { divisor: 4 };
let frames = 100;
simulate_frames(&mut model, &mut strategy, frames, 0);
model.log_distribution("TickStrategyKind::Uniform(4)");
assert_eq!(model.tick_counts.get("A").copied().unwrap_or(0), frames);
assert_eq!(model.tick_counts.get("B").copied().unwrap_or(0), 25);
assert_eq!(model.tick_counts.get("C").copied().unwrap_or(0), 25);
}
#[test]
fn multi_switch_simulation_distributes_ticks() {
let mut model = MockMultiScreenModel::new(&["Home", "Messages", "Settings", "Profile"]);
let config = PredictiveStrategyConfig {
min_observations: 3,
fallback_divisor: 10,
decay_interval: 10_000,
..PredictiveStrategyConfig::default()
};
let mut strategy = Predictive::new(config);
let navigation = [
("Home", 20),
("Messages", 10),
("Home", 15),
("Settings", 5),
("Home", 10),
("Messages", 15),
("Home", 10),
("Profile", 5),
("Home", 10),
];
let mut tick = 0u64;
let mut prev_screen: Option<String> = None;
for (screen, frames) in &navigation {
if let Some(ref prev) = prev_screen
&& prev != screen
{
strategy.on_screen_transition(prev, screen);
}
model.switch_to(screen);
simulate_frames(&mut model, &mut strategy, *frames, tick);
tick += frames;
prev_screen = Some(screen.to_string());
}
model.log_distribution("multi-switch realistic navigation");
let home_ticks = model.tick_counts.get("Home").copied().unwrap_or(0);
let msgs_ticks = model.tick_counts.get("Messages").copied().unwrap_or(0);
let _settings_ticks = model.tick_counts.get("Settings").copied().unwrap_or(0);
let profile_ticks = model.tick_counts.get("Profile").copied().unwrap_or(0);
assert!(
home_ticks > msgs_ticks,
"Home ({home_ticks}) should have more ticks than Messages ({msgs_ticks})"
);
assert!(
msgs_ticks > profile_ticks,
"Messages ({msgs_ticks}) should have more ticks than Profile ({profile_ticks})"
);
assert!(home_ticks > 0, "Home should have ticks");
assert!(msgs_ticks > 0, "Messages should have ticks");
let home_predictions = strategy.predictor().predict(&"Home".to_string());
if !home_predictions.is_empty() {
let top = &home_predictions[0];
eprintln!(
"Top prediction from Home: {} (p={:.3})",
top.screen, top.probability
);
assert_eq!(
top.screen, "Messages",
"Messages should be top prediction from Home"
);
}
}
#[test]
fn decay_reduces_old_transition_influence() {
let config = PredictiveStrategyConfig {
min_observations: 3,
fallback_divisor: 10,
decay_interval: 10,
decay_factor: 0.1, ..PredictiveStrategyConfig::default()
};
let mut strategy = Predictive::new(config);
for _ in 0..50 {
strategy.on_screen_transition("A", "B");
}
let total_before = strategy.counter().total();
eprintln!("total before decay: {total_before}");
for tick in 0..20 {
strategy.maintenance_tick(tick);
}
let total_after = strategy.counter().total();
eprintln!("total after decay: {total_after}");
assert!(
total_after < total_before,
"decay should reduce total: {total_after} < {total_before}"
);
}