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
| Method | Purpose |
|---|---|
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.
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.