Skip to Content
TestingProgramSimulator

ProgramSimulator

ProgramSimulator<M: Model> lets you run a FrankenTUI Model in-process with no terminal, no PTY, no backend. It is the workhorse behind every snapshot test, determinism check, and shadow-run comparison.

Source: crates/ftui-runtime/src/simulator.rs (1700+ lines including tests).

Mental model

The simulator is a deterministic stand-in for Program::run. It owns a model, a GraphemePool, and a Vec<Buffer> of captured frames. You drive it by calling methods in the same order the real runtime would:

ProgramSimulator::new(model) ── no init yet → .init() ── runs Model::init, executes returned Cmd → .send(msg) / .inject_event(evt) / .inject_events(&evts) → .capture_frame(w, h) ── calls Model::view into a fresh Buffer → .model() / .last_frame() ── inspect → repeat until !.is_running()

All commands returned by update are executed by the simulator’s own execute_cmd. Cmd::Quit flips is_running() to false. Cmd::Log is recorded into logs(). Cmd::Task is run synchronously. There is no real clock, no real I/O, no real terminal — just model transitions and Buffer outputs.

API at a glance

MethodPurpose
new(model)Create with defaults. Not initialised.
with_registry(model, registry)As above plus a StateRegistry for Cmd::SaveState/Cmd::RestoreState.
init()Call Model::init(), execute returned Cmd.
send(msg)Dispatch msg through Model::update, execute returned Cmd.
inject_event(evt) / inject_events(&evts)Convert events via From<Event>, dispatch.
capture_frame(w, h)Render Model::view into a fresh Buffer, store, and return.
model() / model_mut()Borrow the underlying model.
frames() / last_frame() / frame_count()Access captured buffers.
is_running()false after Cmd::Quit.
logs()Strings emitted via Cmd::Log.
command_log()CmdRecord trace for every executed command.
tick_rate()Most recent Cmd::Tick duration, if any.
clear_frames() / clear_logs()Reset accumulators.

Frames are checksummed by callers, not by ProgramSimulator itself — the FNV-1a fnv1a_buffer helper lives in ftui-harness’s lab_integration layer. If you need checksums, use LabSession instead of the raw simulator.

Worked example: counter under test

A minimal Model, driven through a full lifecycle. This compiles and runs inside cargo test -p ftui-runtime.

tests/counter_simulator.rs
use ftui_runtime::program::{Cmd, Model}; use ftui_runtime::simulator::ProgramSimulator; use ftui_core::event::Event; use ftui_core::geometry::Rect; use ftui_render::frame::Frame; use ftui_widgets::paragraph::Paragraph; #[derive(Clone, Debug)] enum Msg { Inc, Dec, Quit } impl From<Event> for Msg { fn from(_: Event) -> Self { Msg::Inc } } struct Counter { value: i32 } impl Model for Counter { type Message = Msg; fn update(&mut self, msg: Msg) -> Cmd<Msg> { match msg { Msg::Inc => { self.value += 1; Cmd::none() } Msg::Dec => { self.value -= 1; Cmd::none() } Msg::Quit => Cmd::quit(), } } fn view(&self, frame: &mut Frame) { let s = format!("value={}", self.value); let area = Rect::new(0, 0, frame.width(), 1); Paragraph::new(s.as_str()).render(area, frame); } } #[test] fn counter_tracks_messages_and_quits() { let mut sim = ProgramSimulator::new(Counter { value: 0 }); sim.init(); assert!(sim.is_running()); sim.send(Msg::Inc); sim.send(Msg::Inc); sim.send(Msg::Dec); assert_eq!(sim.model().value, 1); let buf = sim.capture_frame(20, 1); assert!(buf_to_string(buf).starts_with("value=1")); sim.send(Msg::Quit); assert!(!sim.is_running()); // Sending after Quit is a no-op. sim.send(Msg::Inc); assert_eq!(sim.model().value, 1); }

buf_to_string here can be the buffer_to_text helper from ftui-harness — see snapshot tests.

Wiring into snapshot tests

The simulator is most useful as a frame source for snapshot assertions:

use ftui_harness::assert_snapshot; use ftui_runtime::simulator::ProgramSimulator; #[test] fn dashboard_empty_state_snapshot() { let mut sim = ProgramSimulator::new(Dashboard::default()); sim.init(); let buf = sim.capture_frame(80, 24); assert_snapshot!("dashboard_empty_state", buf); }

Run with BLESS=1 to create the snapshot; check it in; rerun to regression-gate it. See snapshot tests for the full workflow.

Pitfalls

Call init() exactly once. ProgramSimulator::new does not call Model::init — messages sent before init() skip the initial command batch. Always start with .init().

Cmd::Task runs synchronously. There is no executor, no runtime. The simulator executes the closure on the current thread. If your task expects a tokio context it will panic — test the effect separately.

No real clock. Cmd::Tick(duration) sets tick_rate but nothing actually fires. To simulate time, send the tick message yourself with .send(your_tick_msg), or use LabSession::tick() which does have a deterministic clock.