#![forbid(unsafe_code)]
use ftui_core::event::Event;
use ftui_render::frame::Frame;
use ftui_runtime::program::{Cmd, Model};
use ftui_runtime::simulator::ProgramSimulator;
use std::time::Duration;
struct ParityModel {
trace: Vec<String>,
}
impl ParityModel {
fn new() -> Self {
Self { trace: vec![] }
}
}
#[derive(Debug)]
enum PMsg {
Init,
Step(String),
Batch(Vec<String>),
Sequence(Vec<String>),
Nested(u32),
TaskSpawn(String),
TaskResult(String),
LogMsg(String),
Tick,
Quit,
QuitInBatch(usize),
}
impl From<Event> for PMsg {
fn from(_: Event) -> Self {
PMsg::Step("event".into())
}
}
impl Model for ParityModel {
type Message = PMsg;
fn init(&mut self) -> Cmd<Self::Message> {
self.trace.push("init".into());
Cmd::msg(PMsg::Init)
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
PMsg::Init => {
self.trace.push("update:init".into());
Cmd::none()
}
PMsg::Step(s) => {
self.trace.push(format!("step:{s}"));
Cmd::none()
}
PMsg::Batch(items) => {
self.trace.push(format!("batch:{}", items.len()));
let cmds: Vec<_> = items.into_iter().map(|s| Cmd::msg(PMsg::Step(s))).collect();
Cmd::batch(cmds)
}
PMsg::Sequence(items) => {
self.trace.push(format!("seq:{}", items.len()));
let cmds: Vec<_> = items.into_iter().map(|s| Cmd::msg(PMsg::Step(s))).collect();
Cmd::sequence(cmds)
}
PMsg::Nested(depth) => {
self.trace.push(format!("nested:{depth}"));
if depth > 0 {
Cmd::msg(PMsg::Nested(depth - 1))
} else {
Cmd::none()
}
}
PMsg::TaskSpawn(label) => {
self.trace.push(format!("task-spawn:{label}"));
let label_clone = label.clone();
Cmd::task(move || PMsg::TaskResult(label_clone))
}
PMsg::TaskResult(label) => {
self.trace.push(format!("task-result:{label}"));
Cmd::none()
}
PMsg::LogMsg(text) => {
self.trace.push(format!("log:{text}"));
Cmd::log(text)
}
PMsg::Tick => {
self.trace.push("tick".into());
Cmd::tick(Duration::from_millis(100))
}
PMsg::Quit => {
self.trace.push("quit".into());
Cmd::quit()
}
PMsg::QuitInBatch(before_count) => {
self.trace.push(format!("quit-in-batch:{before_count}"));
let mut cmds: Vec<Cmd<PMsg>> = (0..before_count)
.map(|i| Cmd::msg(PMsg::Step(format!("pre-quit-{i}"))))
.collect();
cmds.push(Cmd::quit());
cmds.push(Cmd::msg(PMsg::Step("post-quit".into())));
Cmd::batch(cmds)
}
}
}
fn view(&self, _frame: &mut Frame) {}
fn on_shutdown(&mut self) -> Cmd<Self::Message> {
self.trace.push("on_shutdown".into());
Cmd::msg(PMsg::Step("shutdown-step".into()))
}
}
fn run_scenario(msgs: Vec<PMsg>) -> (Vec<String>, Vec<String>, bool) {
let mut sim = ProgramSimulator::new(ParityModel::new());
sim.init();
for msg in msgs {
sim.send(msg);
}
let shutdown_cmd = sim.model_mut().on_shutdown();
let _ = shutdown_cmd;
(
sim.model().trace.clone(),
sim.logs().to_vec(),
sim.is_running(),
)
}
#[test]
fn parity_happy_path_basic_steps() {
let (trace, _, running) = run_scenario(vec![
PMsg::Step("a".into()),
PMsg::Step("b".into()),
PMsg::Step("c".into()),
]);
assert!(running);
assert_eq!(
trace,
vec![
"init",
"update:init",
"step:a",
"step:b",
"step:c",
"on_shutdown",
]
);
}
#[test]
fn parity_batch_preserves_order() {
let (trace, _, _) = run_scenario(vec![PMsg::Batch(vec!["x".into(), "y".into(), "z".into()])]);
assert_eq!(
trace,
vec![
"init",
"update:init",
"batch:3",
"step:x",
"step:y",
"step:z",
"on_shutdown",
]
);
}
#[test]
fn parity_sequence_preserves_order() {
let (trace, _, _) = run_scenario(vec![PMsg::Sequence(vec![
"p".into(),
"q".into(),
"r".into(),
])]);
assert_eq!(
trace,
vec![
"init",
"update:init",
"seq:3",
"step:p",
"step:q",
"step:r",
"on_shutdown",
]
);
}
#[test]
fn parity_task_spawn_and_result() {
let (trace, _, _) = run_scenario(vec![
PMsg::TaskSpawn("alpha".into()),
PMsg::TaskSpawn("beta".into()),
]);
assert_eq!(
trace,
vec![
"init",
"update:init",
"task-spawn:alpha",
"task-result:alpha",
"task-spawn:beta",
"task-result:beta",
"on_shutdown",
]
);
}
#[test]
fn parity_log_emits_to_sink() {
let (trace, logs, _) = run_scenario(vec![
PMsg::LogMsg("hello".into()),
PMsg::LogMsg("world".into()),
]);
assert_eq!(logs, vec!["hello", "world"]);
assert!(trace.contains(&"log:hello".to_string()));
assert!(trace.contains(&"log:world".to_string()));
}
#[test]
fn parity_tick_sets_rate() {
let mut sim = ProgramSimulator::new(ParityModel::new());
sim.init();
sim.send(PMsg::Tick);
assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
}
#[test]
fn parity_quit_stops_processing() {
let (trace, _, running) = run_scenario(vec![
PMsg::Step("before".into()),
PMsg::Quit,
PMsg::Step("after".into()),
]);
assert!(!running);
assert!(trace.contains(&"step:before".to_string()));
assert!(trace.contains(&"quit".to_string()));
assert!(
!trace.contains(&"step:after".to_string()),
"messages after Quit must not be processed"
);
}
#[test]
fn parity_quit_in_batch_halts_remaining() {
let (trace, _, running) = run_scenario(vec![PMsg::QuitInBatch(2)]);
assert!(!running);
assert!(trace.contains(&"step:pre-quit-0".to_string()));
assert!(trace.contains(&"step:pre-quit-1".to_string()));
assert!(
!trace.contains(&"step:post-quit".to_string()),
"commands after Quit in batch must not execute"
);
}
#[test]
fn parity_nested_recursion_depth_10() {
let (trace, _, _) = run_scenario(vec![PMsg::Nested(10)]);
for i in 0..=10 {
assert!(trace.contains(&format!("nested:{i}")), "missing nested:{i}");
}
}
#[test]
fn parity_large_batch_100_items() {
let items: Vec<String> = (0..100).map(|i| format!("item-{i}")).collect();
let (trace, _, _) = run_scenario(vec![PMsg::Batch(items)]);
assert!(trace.contains(&"batch:100".to_string()));
assert!(trace.contains(&"step:item-0".to_string()));
assert!(trace.contains(&"step:item-99".to_string()));
let step_positions: Vec<usize> = trace
.iter()
.enumerate()
.filter_map(|(i, s)| {
if s.starts_with("step:item-") {
Some(i)
} else {
None
}
})
.collect();
assert_eq!(step_positions.len(), 100);
for window in step_positions.windows(2) {
assert!(window[0] < window[1]);
}
}
#[test]
fn parity_interleaved_batch_and_sequence() {
let (trace, _, _) = run_scenario(vec![
PMsg::Batch(vec!["b1".into(), "b2".into()]),
PMsg::Sequence(vec!["s1".into(), "s2".into()]),
PMsg::Step("final".into()),
]);
assert_eq!(
trace,
vec![
"init",
"update:init",
"batch:2",
"step:b1",
"step:b2",
"seq:2",
"step:s1",
"step:s2",
"step:final",
"on_shutdown",
]
);
}
#[test]
fn parity_on_shutdown_runs_after_quit() {
let (trace, _, _) = run_scenario(vec![PMsg::Quit]);
let quit_pos = trace.iter().position(|s| s == "quit").unwrap();
let shutdown_pos = trace.iter().position(|s| s == "on_shutdown").unwrap();
assert!(shutdown_pos > quit_pos, "on_shutdown must run after quit");
}
#[test]
fn parity_shadow_comparison_deterministic() {
let scenario = vec![
PMsg::Step("a".into()),
PMsg::Batch(vec!["b1".into(), "b2".into()]),
PMsg::TaskSpawn("t1".into()),
PMsg::Nested(3),
PMsg::LogMsg("log1".into()),
PMsg::Sequence(vec!["s1".into(), "s2".into()]),
PMsg::TaskSpawn("t2".into()),
];
let mut results = Vec::new();
for _ in 0..5 {
let msgs = vec![
PMsg::Step("a".into()),
PMsg::Batch(vec!["b1".into(), "b2".into()]),
PMsg::TaskSpawn("t1".into()),
PMsg::Nested(3),
PMsg::LogMsg("log1".into()),
PMsg::Sequence(vec!["s1".into(), "s2".into()]),
PMsg::TaskSpawn("t2".into()),
];
results.push(run_scenario(msgs));
}
let (ref_trace, ref_logs, ref_running) = &results[0];
for (i, (trace, logs, running)) in results.iter().enumerate().skip(1) {
assert_eq!(trace, ref_trace, "run {i} trace diverged from run 0");
assert_eq!(logs, ref_logs, "run {i} logs diverged from run 0");
assert_eq!(
running, ref_running,
"run {i} running state diverged from run 0"
);
}
let _ = scenario; assert_eq!(
ref_trace,
&[
"init",
"update:init",
"step:a",
"batch:2",
"step:b1",
"step:b2",
"task-spawn:t1",
"task-result:t1",
"nested:3",
"nested:2",
"nested:1",
"nested:0",
"log:log1",
"seq:2",
"step:s1",
"step:s2",
"task-spawn:t2",
"task-result:t2",
"on_shutdown",
]
);
}
#[test]
fn parity_shadow_comparison_quit_cutoff_deterministic() {
let mut results = Vec::new();
for _ in 0..5 {
let msgs = vec![
PMsg::Step("before-1".into()),
PMsg::Step("before-2".into()),
PMsg::QuitInBatch(3),
PMsg::Step("never".into()),
];
results.push(run_scenario(msgs));
}
let (ref_trace, _, _) = &results[0];
for (i, (trace, _, _)) in results.iter().enumerate().skip(1) {
assert_eq!(trace, ref_trace, "quit-cutoff run {i} diverged from run 0");
}
assert!(!ref_trace.contains(&"step:never".to_string()));
assert!(!ref_trace.contains(&"step:post-quit".to_string()));
}
#[test]
fn parity_effect_metrics_command_counter() {
let before = ftui_runtime::effect_system::effects_command_total();
ftui_runtime::effect_system::trace_command_effect("parity-test", || {
});
let after = ftui_runtime::effect_system::effects_command_total();
assert_eq!(
after,
before + 1,
"trace_command_effect must increment counter by 1"
);
}
#[test]
fn parity_effect_metrics_combined_total_formula() {
let cmd = ftui_runtime::effect_system::effects_command_total();
let sub = ftui_runtime::effect_system::effects_subscription_total();
let total = ftui_runtime::effect_system::effects_executed_total();
assert_eq!(
total,
cmd + sub,
"combined total must always equal cmd + sub"
);
}