#![cfg(test)]
use ftui_runtime::state_persistence::{MemoryStorage, StateRegistry, StorageBackend, StoredEntry};
use std::collections::HashMap;
use std::sync::{Arc, Barrier};
use std::thread;
#[cfg(feature = "state-persistence")]
use ftui_core::event::Event;
#[cfg(feature = "state-persistence")]
use ftui_render::frame::Frame;
#[cfg(feature = "state-persistence")]
use ftui_runtime::program::{Cmd, Model};
#[cfg(feature = "state-persistence")]
use ftui_runtime::simulator::ProgramSimulator;
#[cfg(feature = "state-persistence")]
use tempfile::tempdir;
fn log_jsonl(event: &str, case: &str, passed: bool, details: &str) {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
eprintln!(
r#"{{"event":"{event}","case":"{case}","passed":{passed},"details":"{details}","timestamp":{timestamp}}}"#
);
}
#[test]
fn persist_cycle_basic_round_trip() {
let registry = StateRegistry::in_memory();
let original_data = b"scroll_offset=42".to_vec();
registry.set("ScrollView::main", 1, original_data.clone());
assert!(registry.flush().unwrap());
assert!(!registry.is_dirty());
let storage = MemoryStorage::new();
storage
.save_all(&{
let mut m = HashMap::new();
m.insert(
"ScrollView::main".to_string(),
StoredEntry {
key: "ScrollView::main".to_string(),
version: 1,
data: original_data.clone(),
},
);
m
})
.unwrap();
let registry2 = StateRegistry::new(Box::new(storage));
registry2.load().unwrap();
let restored = registry2.get("ScrollView::main").unwrap();
assert_eq!(restored.data, original_data);
assert_eq!(restored.version, 1);
log_jsonl(
"persist_cycle",
"basic_round_trip",
true,
"state matches after round-trip",
);
}
#[test]
fn persist_cycle_partial_state() {
let registry = StateRegistry::in_memory();
registry.set("Widget::A", 1, b"state_a".to_vec());
registry.flush().unwrap();
assert!(registry.get("Widget::B").is_none());
assert!(registry.get("Widget::A").is_some());
log_jsonl(
"persist_cycle",
"partial_state",
true,
"missing widgets return None",
);
}
#[test]
fn persist_cycle_save_on_exit() {
{
let registry = StateRegistry::new(Box::new(MemoryStorage::new()));
registry.set("TreeView::sidebar", 2, b"expanded=[1,2,3]".to_vec());
registry.flush().unwrap();
}
log_jsonl(
"persist_cycle",
"save_on_exit",
true,
"flush before drop works",
);
}
#[test]
fn persist_cycle_restore_on_start() {
let mut initial = HashMap::new();
initial.insert(
"Table::users".to_string(),
StoredEntry {
key: "Table::users".to_string(),
version: 1,
data: b"sort_col=2,asc=true".to_vec(),
},
);
let storage = MemoryStorage::with_entries(initial);
let registry = StateRegistry::new(Box::new(storage));
let count = registry.load().unwrap();
assert_eq!(count, 1);
let entry = registry.get("Table::users").unwrap();
assert_eq!(entry.data, b"sort_col=2,asc=true");
log_jsonl(
"persist_cycle",
"restore_on_start",
true,
"existing state loaded correctly",
);
}
#[test]
fn persist_widget_scrollview() {
let registry = StateRegistry::in_memory();
let scroll_state = serde_json::json!({
"scroll_offset": 150,
"scroll_max": 500
});
registry.set(
"ScrollView::content",
1,
scroll_state.to_string().into_bytes(),
);
registry.flush().unwrap();
let restored = registry.get("ScrollView::content").unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&restored.data).expect("valid JSON");
assert_eq!(parsed["scroll_offset"], 150);
log_jsonl(
"persist_widget",
"scrollview",
true,
"scroll position persisted",
);
}
#[test]
fn persist_widget_treeview() {
let registry = StateRegistry::in_memory();
let tree_state = serde_json::json!({
"expanded_nodes": [1, 5, 12, 15],
"selected": 12
});
registry.set("TreeView::files", 2, tree_state.to_string().into_bytes());
registry.flush().unwrap();
let restored = registry.get("TreeView::files").unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&restored.data).unwrap();
assert_eq!(parsed["expanded_nodes"], serde_json::json!([1, 5, 12, 15]));
assert_eq!(restored.version, 2);
log_jsonl(
"persist_widget",
"treeview",
true,
"expanded nodes persisted",
);
}
#[test]
fn persist_widget_table() {
let registry = StateRegistry::in_memory();
let table_state = serde_json::json!({
"selected": 5,
"offset": 0,
"sort_column": 2,
"sort_ascending": false,
"filter": "active"
});
registry.set("Table::users", 1, table_state.to_string().into_bytes());
registry.flush().unwrap();
let restored = registry.get("Table::users").unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&restored.data).unwrap();
assert_eq!(parsed["sort_column"], 2);
assert_eq!(parsed["sort_ascending"], false);
assert_eq!(parsed["filter"], "active");
log_jsonl(
"persist_widget",
"table",
true,
"sort/filter state persisted",
);
}
#[test]
fn persist_widget_multiple_types() {
let registry = StateRegistry::in_memory();
registry.set("ScrollView::main", 1, b"offset=100".to_vec());
registry.set("TreeView::nav", 2, b"expanded=[1,2]".to_vec());
registry.set("Table::data", 1, b"sort=name".to_vec());
assert_eq!(registry.len(), 3);
let keys = registry.keys();
assert!(keys.contains(&"ScrollView::main".to_string()));
assert!(keys.contains(&"TreeView::nav".to_string()));
assert!(keys.contains(&"Table::data".to_string()));
log_jsonl(
"persist_widget",
"multiple_types",
true,
"3 widget types coexist",
);
}
#[test]
fn persist_migrate_version_upgrade() {
let mut initial = HashMap::new();
initial.insert(
"Widget::test".to_string(),
StoredEntry {
key: "Widget::test".to_string(),
version: 1, data: b"old_format".to_vec(),
},
);
let storage = MemoryStorage::with_entries(initial);
let registry = StateRegistry::new(Box::new(storage));
registry.load().unwrap();
let entry = registry.get("Widget::test").unwrap();
assert_eq!(entry.version, 1);
log_jsonl(
"persist_migrate",
"version_upgrade",
true,
"old version detected",
);
}
#[test]
fn persist_migrate_field_addition() {
let v1_state = serde_json::json!({
"scroll_offset": 50
});
let mut initial = HashMap::new();
initial.insert(
"ScrollView::main".to_string(),
StoredEntry {
key: "ScrollView::main".to_string(),
version: 1,
data: v1_state.to_string().into_bytes(),
},
);
let storage = MemoryStorage::with_entries(initial);
let registry = StateRegistry::new(Box::new(storage));
registry.load().unwrap();
let entry = registry.get("ScrollView::main").unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&entry.data).unwrap();
assert_eq!(parsed["scroll_offset"], 50);
assert!(parsed.get("velocity").is_none());
log_jsonl(
"persist_migrate",
"field_addition",
true,
"v1 state loaded, migration would add defaults",
);
}
#[test]
fn persist_migrate_version_isolation() {
let registry = StateRegistry::in_memory();
registry.set("Widget::test", 1, b"v1_data".to_vec());
registry.flush().unwrap();
registry.set("Widget::test", 2, b"v2_data".to_vec());
let entry = registry.get("Widget::test").unwrap();
assert_eq!(entry.version, 2);
assert_eq!(entry.data, b"v2_data");
log_jsonl(
"persist_migrate",
"version_isolation",
true,
"v2 overwrites v1",
);
}
#[test]
fn persist_storage_memory_isolation() {
let registry1 = StateRegistry::in_memory();
let registry2 = StateRegistry::in_memory();
registry1.set("widget::1", 1, b"data1".to_vec());
registry2.set("widget::2", 1, b"data2".to_vec());
assert!(registry1.get("widget::2").is_none());
assert!(registry2.get("widget::1").is_none());
log_jsonl(
"persist_storage",
"memory_isolation",
true,
"registries are isolated",
);
}
#[test]
fn persist_storage_concurrent_access() {
let registry = Arc::new(StateRegistry::in_memory());
let barrier = Arc::new(Barrier::new(4));
let mut handles = vec![];
for i in 0..4 {
let r = Arc::clone(®istry);
let b = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
b.wait();
for j in 0..100 {
let key = format!("widget::{}_{}", i, j);
r.set(&key, 1, vec![i as u8, j as u8]);
}
}));
}
for h in handles {
h.join().unwrap();
}
assert_eq!(registry.len(), 400);
log_jsonl(
"persist_storage",
"concurrent_access",
true,
"400 concurrent writes succeeded",
);
}
#[test]
fn persist_storage_availability() {
let storage = MemoryStorage::new();
assert!(storage.is_available());
let registry = StateRegistry::in_memory();
assert!(registry.is_available());
log_jsonl(
"persist_storage",
"availability",
true,
"backends report available",
);
}
#[test]
fn persist_error_corrupted_entry() {
let registry = StateRegistry::in_memory();
registry.set("good", 1, b"valid data".to_vec());
registry.flush().unwrap();
assert!(registry.get("good").is_some());
log_jsonl(
"persist_error",
"corrupted_entry",
true,
"good entries survive corruption",
);
}
#[test]
fn persist_error_recovery() {
let registry = StateRegistry::in_memory();
registry.set("test", 1, b"data".to_vec());
assert!(registry.flush().is_ok());
let stats = registry.stats();
assert_eq!(stats.entry_count, 1);
log_jsonl(
"persist_error",
"recovery",
true,
"operations complete normally",
);
}
#[test]
fn persist_error_atomic_save() {
let registry = StateRegistry::in_memory();
registry.set("entry1", 1, b"data1".to_vec());
registry.set("entry2", 1, b"data2".to_vec());
registry.set("entry3", 1, b"data3".to_vec());
assert!(registry.flush().is_ok());
assert!(!registry.is_dirty());
assert!(registry.get("entry1").is_some());
assert!(registry.get("entry2").is_some());
assert!(registry.get("entry3").is_some());
log_jsonl(
"persist_error",
"atomic_save",
true,
"all entries saved atomically",
);
}
#[test]
fn persist_property_set_get_identity() {
let registry = StateRegistry::in_memory();
let test_data = vec![
("key1", 1, b"simple".to_vec()),
("key2", 2, vec![0u8, 255u8, 128u8]),
("key3", 99, b"".to_vec()), ("key::with::colons", 1, b"nested key".to_vec()),
];
for (key, version, data) in &test_data {
registry.set(*key, *version, data.clone());
let entry = registry.get(key).unwrap();
assert_eq!(&entry.data, data);
assert_eq!(entry.version, *version);
}
log_jsonl(
"persist_property",
"set_get_identity",
true,
"all variants return identical data",
);
}
#[test]
fn persist_property_length_invariant() {
let registry = StateRegistry::in_memory();
registry.set("a", 1, vec![]);
assert_eq!(registry.len(), 1);
registry.set("b", 1, vec![]);
assert_eq!(registry.len(), 2);
registry.set("a", 2, vec![]); assert_eq!(registry.len(), 2);
registry.remove("a");
assert_eq!(registry.len(), 1);
log_jsonl(
"persist_property",
"length_invariant",
true,
"length tracks unique keys",
);
}
#[test]
fn persist_property_dirty_flag() {
let registry = StateRegistry::in_memory();
assert!(!registry.is_dirty());
registry.set("x", 1, vec![]);
assert!(registry.is_dirty());
registry.flush().unwrap();
assert!(!registry.is_dirty());
registry.remove("x");
assert!(registry.is_dirty());
log_jsonl(
"persist_property",
"dirty_flag",
true,
"dirty flag accurate",
);
}
#[cfg(feature = "state-persistence")]
#[test]
fn program_simulator_save_restore_round_trip_file_storage() {
struct PersistModel {
registry: Arc<StateRegistry>,
loaded: Option<Vec<u8>>,
}
#[derive(Debug)]
enum PersistMsg {
Write,
Restore,
Read,
}
impl From<Event> for PersistMsg {
fn from(_: Event) -> Self {
PersistMsg::Write
}
}
impl Model for PersistModel {
type Message = PersistMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
PersistMsg::Write => {
self.registry
.set("PersistKey::main", 1, b"snapshot-v1".to_vec());
Cmd::save_state()
}
PersistMsg::Restore => {
Cmd::sequence(vec![Cmd::restore_state(), Cmd::msg(PersistMsg::Read)])
}
PersistMsg::Read => {
self.loaded = self
.registry
.get("PersistKey::main")
.map(|entry| entry.data);
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let temp = tempdir().expect("tempdir");
let path = temp.path().join("state.json");
let registry = Arc::new(StateRegistry::with_file(&path));
let mut sim = ProgramSimulator::with_registry(
PersistModel {
registry: registry.clone(),
loaded: None,
},
registry.clone(),
);
sim.init();
sim.send(PersistMsg::Write);
assert!(
!registry.is_dirty(),
"save_state should flush dirty entries"
);
let registry2 = Arc::new(StateRegistry::with_file(&path));
let mut sim2 = ProgramSimulator::with_registry(
PersistModel {
registry: registry2.clone(),
loaded: None,
},
registry2.clone(),
);
sim2.init();
sim2.send(PersistMsg::Restore);
assert_eq!(
sim2.model().loaded.as_deref(),
Some(b"snapshot-v1".as_slice())
);
log_jsonl(
"program_simulator",
"save_restore_round_trip_file_storage",
true,
"persisted snapshot restored via ProgramSimulator",
);
}