#![forbid(unsafe_code)]
use core::time::Duration;
use ftui_backend::{BackendClock, BackendEventSource, BackendPresenter};
use ftui_core::event::Event;
use ftui_render::buffer::{Buffer, DoubleBuffer};
use ftui_render::diff::BufferDiff;
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
use ftui_runtime::program::{Cmd, Model};
use crate::{WebBackend, WebBackendError, WebOutputs};
const POOL_GC_INTERVAL_FRAMES: u64 = 256;
const MIN_TERMINAL_DIMENSION: u16 = 1;
#[inline]
fn clamp_terminal_dimension(value: u16) -> u16 {
if value < MIN_TERMINAL_DIMENSION {
MIN_TERMINAL_DIMENSION
} else {
value
}
}
#[inline]
fn clamp_terminal_size(width: u16, height: u16) -> (u16, u16) {
(
clamp_terminal_dimension(width),
clamp_terminal_dimension(height),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StepResult {
pub running: bool,
pub rendered: bool,
pub events_processed: u32,
pub frame_idx: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct GeometryTransition {
from_cols: u16,
from_rows: u16,
to_cols: u16,
to_rows: u16,
}
pub struct StepProgram<M: Model> {
model: M,
backend: WebBackend,
pool: GraphemePool,
running: bool,
initialized: bool,
dirty: bool,
frame_idx: u64,
tick_rate: Option<Duration>,
last_tick: Duration,
width: u16,
height: u16,
dbl_buf: Option<DoubleBuffer>,
pending_geometry_transition: Option<GeometryTransition>,
}
impl<M: Model> StepProgram<M> {
#[must_use]
pub fn new(model: M, width: u16, height: u16) -> Self {
let (width, height) = clamp_terminal_size(width, height);
Self {
model,
backend: WebBackend::new(width, height),
pool: GraphemePool::new(),
running: true,
initialized: false,
dirty: true,
frame_idx: 0,
tick_rate: None,
last_tick: Duration::ZERO,
width,
height,
dbl_buf: None,
pending_geometry_transition: None,
}
}
#[must_use]
pub fn with_backend(model: M, mut backend: WebBackend) -> Self {
let (raw_width, raw_height) = backend.events_mut().size().unwrap_or((80, 24));
let (width, height) = clamp_terminal_size(raw_width, raw_height);
backend.events_mut().set_size(width, height);
Self {
model,
backend,
pool: GraphemePool::new(),
running: true,
initialized: false,
dirty: true,
frame_idx: 0,
tick_rate: None,
last_tick: Duration::ZERO,
width,
height,
dbl_buf: None,
pending_geometry_transition: None,
}
}
pub fn init(&mut self) -> Result<(), WebBackendError> {
assert!(!self.initialized, "StepProgram::init() called twice");
self.initialized = true;
let cmd = self.model.init();
self.execute_cmd(cmd);
if self.running {
self.render_frame()?;
}
Ok(())
}
pub fn step(&mut self) -> Result<StepResult, WebBackendError> {
assert!(self.initialized, "StepProgram::step() called before init()");
if !self.running {
return Ok(StepResult {
running: false,
rendered: false,
events_processed: 0,
frame_idx: self.frame_idx,
});
}
let mut events_processed: u32 = 0;
while let Some(event) = self.backend.events.read_event()? {
events_processed += 1;
self.handle_event(event);
if !self.running {
break;
}
}
if self.running
&& let Some(rate) = self.tick_rate
{
let now = self.backend.clock.now_mono();
let delta = now.saturating_sub(self.last_tick);
let should_tick = if rate.is_zero() { true } else { delta >= rate };
if should_tick {
if rate.is_zero() {
self.last_tick = now;
} else {
let rem_ns = delta.as_nanos() % rate.as_nanos();
let rem = Duration::from_nanos(rem_ns as u64);
self.last_tick = now.saturating_sub(rem);
}
let msg = M::Message::from(Event::Tick);
let cmd = self.model.update(msg);
self.dirty = true;
self.execute_cmd(cmd);
}
}
let rendered = if self.running && self.dirty {
self.render_frame()?;
true
} else {
false
};
Ok(StepResult {
running: self.running,
rendered,
events_processed,
frame_idx: self.frame_idx,
})
}
pub fn push_event(&mut self, event: Event) {
let event = match event {
Event::Resize { width, height } => {
let (width, height) = clamp_terminal_size(width, height);
self.backend.events_mut().set_size(width, height);
Event::Resize { width, height }
}
other => other,
};
self.backend.events_mut().push_event(event);
}
pub fn advance_time(&mut self, dt: Duration) {
self.backend.clock_mut().advance(dt);
}
pub fn set_time(&mut self, now: Duration) {
self.backend.clock_mut().set(now);
}
pub fn resize(&mut self, width: u16, height: u16) {
self.push_event(Event::Resize { width, height });
}
pub fn take_outputs(&mut self) -> WebOutputs {
self.backend.presenter_mut().take_outputs()
}
pub fn outputs(&self) -> &WebOutputs {
self.backend.presenter.outputs()
}
pub fn model(&self) -> &M {
&self.model
}
pub fn model_mut(&mut self) -> &mut M {
&mut self.model
}
pub fn backend(&self) -> &WebBackend {
&self.backend
}
pub fn backend_mut(&mut self) -> &mut WebBackend {
&mut self.backend
}
pub fn is_running(&self) -> bool {
self.running
}
pub fn is_initialized(&self) -> bool {
self.initialized
}
pub fn frame_idx(&self) -> u64 {
self.frame_idx
}
pub fn size(&self) -> (u16, u16) {
(self.width, self.height)
}
pub fn tick_rate(&self) -> Option<Duration> {
self.tick_rate
}
pub fn pool(&self) -> &GraphemePool {
&self.pool
}
fn handle_event(&mut self, event: Event) {
if let Event::Resize { width, height } = &event {
let (prev_width, prev_height) = (self.width, self.height);
self.width = *width;
self.height = *height;
self.dbl_buf = None;
self.pending_geometry_transition = Some(GeometryTransition {
from_cols: prev_width,
from_rows: prev_height,
to_cols: *width,
to_rows: *height,
});
}
let msg = M::Message::from(event);
let cmd = self.model.update(msg);
self.dirty = true;
self.execute_cmd(cmd);
}
fn render_frame(&mut self) -> Result<(), WebBackendError> {
let full_repaint = self.dbl_buf.is_none();
let geometry_transition = if full_repaint {
self.pending_geometry_transition.take()
} else {
None
};
if self.dbl_buf.is_none() {
self.dbl_buf = Some(DoubleBuffer::new(self.width, self.height));
}
{
let dbl = self.dbl_buf.as_mut().unwrap();
dbl.swap();
dbl.current_mut().clear();
}
let render_buf = std::mem::replace(
self.dbl_buf.as_mut().unwrap().current_mut(),
Buffer::new(1, 1),
);
let mut frame = Frame::from_buffer(render_buf, &mut self.pool);
self.model.view(&mut frame);
*self.dbl_buf.as_mut().unwrap().current_mut() = frame.buffer;
let dbl = self.dbl_buf.as_ref().unwrap();
let diff = if full_repaint {
None
} else {
Some(BufferDiff::compute(dbl.previous(), dbl.current()))
};
let buf = dbl.current().clone();
self.backend
.presenter_mut()
.present_ui_owned(buf, diff.as_ref(), full_repaint);
if let Some(transition) = geometry_transition {
self.emit_geometry_transition_markers(transition);
}
self.dirty = false;
self.frame_idx += 1;
if self.frame_idx.is_multiple_of(POOL_GC_INTERVAL_FRAMES) {
let Self { dbl_buf, pool, .. } = self;
let dbl = dbl_buf.as_ref().unwrap();
pool.gc(&[dbl.current(), dbl.previous()]);
}
Ok(())
}
fn emit_geometry_transition_markers(&mut self, transition: GeometryTransition) {
let reset_marker = format!(
r#"{{"event":"diff_baseline_reset","reason":"geometry_transition","from_cols":{},"from_rows":{},"to_cols":{},"to_rows":{},"frame_idx":{}}}"#,
transition.from_cols,
transition.from_rows,
transition.to_cols,
transition.to_rows,
self.frame_idx
);
let repaint_marker = format!(
r#"{{"event":"full_repaint_boundary","reason":"geometry_transition","from_cols":{},"from_rows":{},"to_cols":{},"to_rows":{},"frame_idx":{},"full_repaint":true}}"#,
transition.from_cols,
transition.from_rows,
transition.to_cols,
transition.to_rows,
self.frame_idx
);
let presenter = self.backend.presenter_mut();
let _ = presenter.write_log(&reset_marker);
let _ = presenter.write_log(&repaint_marker);
}
fn execute_cmd(&mut self, cmd: Cmd<M::Message>) {
match cmd {
Cmd::None => {}
Cmd::Quit => {
self.running = false;
}
Cmd::Msg(m) => {
let cmd = self.model.update(m);
self.execute_cmd(cmd);
}
Cmd::Batch(cmds) => {
for c in cmds {
self.execute_cmd(c);
if !self.running {
break;
}
}
}
Cmd::Sequence(cmds) => {
for c in cmds {
self.execute_cmd(c);
if !self.running {
break;
}
}
}
Cmd::Tick(duration) => {
self.tick_rate = Some(duration);
}
Cmd::Log(text) => {
let _ = self.backend.presenter_mut().write_log(&text);
}
Cmd::Task(_spec, f) => {
let msg = f();
let cmd = self.model.update(msg);
self.execute_cmd(cmd);
}
Cmd::SetMouseCapture(enabled) => {
let mut features = self.backend.events_mut().features();
features.mouse_capture = enabled;
let _ = self.backend.events_mut().set_features(features);
}
Cmd::SaveState | Cmd::RestoreState => {
}
Cmd::SetTickStrategy(_) => {
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
use ftui_render::cell::Cell;
use ftui_render::drawing::Draw;
use pretty_assertions::assert_eq;
struct Counter {
value: i32,
initialized: bool,
}
#[derive(Debug)]
enum CounterMsg {
Increment,
Decrement,
Reset,
Quit,
LogValue,
BatchIncrement(usize),
SpawnTask,
}
impl From<Event> for CounterMsg {
fn from(event: Event) -> Self {
match event {
Event::Key(k) if k.code == KeyCode::Char('+') => CounterMsg::Increment,
Event::Key(k) if k.code == KeyCode::Char('-') => CounterMsg::Decrement,
Event::Key(k) if k.code == KeyCode::Char('r') => CounterMsg::Reset,
Event::Key(k) if k.code == KeyCode::Char('q') => CounterMsg::Quit,
Event::Tick => CounterMsg::Increment,
_ => CounterMsg::Increment,
}
}
}
impl Model for Counter {
type Message = CounterMsg;
fn init(&mut self) -> Cmd<Self::Message> {
self.initialized = true;
Cmd::none()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
CounterMsg::Increment => {
self.value += 1;
Cmd::none()
}
CounterMsg::Decrement => {
self.value -= 1;
Cmd::none()
}
CounterMsg::Reset => {
self.value = 0;
Cmd::none()
}
CounterMsg::Quit => Cmd::quit(),
CounterMsg::LogValue => Cmd::log(format!("value={}", self.value)),
CounterMsg::BatchIncrement(n) => {
let cmds: Vec<_> = (0..n).map(|_| Cmd::msg(CounterMsg::Increment)).collect();
Cmd::batch(cmds)
}
CounterMsg::SpawnTask => Cmd::task(|| CounterMsg::Increment),
}
}
fn view(&self, frame: &mut Frame) {
let text = format!("Count: {}", self.value);
for (i, c) in text.chars().enumerate() {
if (i as u16) < frame.width() {
frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
}
}
}
}
struct GraphemeChurn {
value: u32,
}
impl Model for GraphemeChurn {
type Message = CounterMsg;
fn init(&mut self) -> Cmd<Self::Message> {
Cmd::none()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
if let CounterMsg::Increment = msg {
self.value = self.value.wrapping_add(1);
}
Cmd::none()
}
fn view(&self, frame: &mut Frame) {
let base = char::from_u32(0x4e00 + (self.value % 2048)).unwrap_or('å—');
let text = format!("{base}\u{0301}");
frame.print_text(0, 0, &text, Cell::default());
}
}
fn key_event(c: char) -> Event {
Event::Key(KeyEvent {
code: KeyCode::Char(c),
modifiers: Modifiers::empty(),
kind: KeyEventKind::Press,
})
}
fn new_counter(value: i32) -> Counter {
Counter {
value,
initialized: false,
}
}
fn new_grapheme_churn() -> GraphemeChurn {
GraphemeChurn { value: 0 }
}
#[test]
fn new_creates_uninitialized_program() {
let prog = StepProgram::new(new_counter(0), 80, 24);
assert!(!prog.is_initialized());
assert!(prog.is_running());
assert_eq!(prog.size(), (80, 24));
assert_eq!(prog.frame_idx(), 0);
assert!(prog.tick_rate().is_none());
}
#[test]
fn new_clamps_zero_dimensions_to_minimum() {
let prog = StepProgram::new(new_counter(0), 0, 0);
assert_eq!(prog.size(), (1, 1));
}
#[test]
fn with_backend_clamps_zero_dimensions_to_minimum() {
let backend = WebBackend::new(0, 0);
let prog = StepProgram::with_backend(new_counter(0), backend);
assert_eq!(prog.size(), (1, 1));
}
#[test]
fn init_initializes_model_and_renders_first_frame() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
assert!(prog.is_initialized());
assert!(prog.model().initialized);
assert_eq!(prog.frame_idx(), 1);
let outputs = prog.outputs();
assert!(outputs.last_buffer.is_some());
assert!(outputs.last_full_repaint_hint); assert_eq!(outputs.last_patches.len(), 1);
let stats = outputs
.last_patch_stats
.expect("patch stats should be captured");
assert_eq!(stats.patch_count, 1);
assert_eq!(stats.dirty_cells, 80 * 24);
}
#[test]
#[should_panic(expected = "init() called twice")]
fn double_init_panics() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.init().unwrap();
}
#[test]
#[should_panic(expected = "step() called before init()")]
fn step_before_init_panics() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
let _ = prog.step();
}
#[test]
fn step_processes_pushed_events() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.push_event(key_event('+'));
prog.push_event(key_event('+'));
prog.push_event(key_event('+'));
let result = prog.step().unwrap();
assert!(result.running);
assert!(result.rendered);
assert_eq!(result.events_processed, 3);
assert_eq!(prog.model().value, 3);
}
#[test]
fn step_with_no_events_does_not_render() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.take_outputs();
let result = prog.step().unwrap();
assert!(result.running);
assert!(!result.rendered);
assert_eq!(result.events_processed, 0);
}
#[test]
fn quit_event_stops_program() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.push_event(key_event('+'));
prog.push_event(key_event('q'));
prog.push_event(key_event('+')); let result = prog.step().unwrap();
assert!(!result.running);
assert!(!prog.is_running());
assert_eq!(prog.model().value, 1); }
#[test]
fn step_after_quit_returns_immediately() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.push_event(key_event('q'));
prog.step().unwrap();
prog.push_event(key_event('+'));
let result = prog.step().unwrap();
assert!(!result.running);
assert!(!result.rendered);
assert_eq!(result.events_processed, 0);
assert_eq!(prog.model().value, 0);
}
#[test]
fn resize_updates_dimensions() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.resize(120, 40);
prog.step().unwrap();
assert_eq!(prog.size(), (120, 40));
}
#[test]
fn resize_clamps_zero_dimensions_to_minimum() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.resize(0, 0);
prog.step().unwrap();
assert_eq!(prog.size(), (1, 1));
let outputs = prog.outputs();
let buf = outputs.last_buffer.as_ref().expect("resize should render");
assert_eq!(buf.width(), 1);
assert_eq!(buf.height(), 1);
}
#[test]
fn resize_produces_correctly_sized_buffer() {
let mut prog = StepProgram::new(new_counter(42), 80, 24);
prog.init().unwrap();
prog.resize(40, 10);
prog.step().unwrap();
let outputs = prog.outputs();
let buf = outputs.last_buffer.as_ref().unwrap();
assert_eq!(buf.width(), 40);
assert_eq!(buf.height(), 10);
}
#[test]
fn resize_emits_baseline_reset_and_full_repaint_markers() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
let _ = prog.take_outputs();
prog.resize(120, 40);
prog.step().unwrap();
let outputs = prog.outputs();
assert!(
outputs
.logs
.iter()
.any(|line| line.contains(r#""event":"diff_baseline_reset""#))
);
assert!(
outputs
.logs
.iter()
.any(|line| line.contains(r#""event":"full_repaint_boundary""#))
);
assert!(
outputs
.logs
.iter()
.any(|line| line.contains(r#""from_cols":80"#) && line.contains(r#""to_cols":120"#))
);
}
#[test]
fn same_size_resize_still_forces_repaint_boundary() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
let _ = prog.take_outputs();
prog.resize(80, 24);
prog.step().unwrap();
let outputs = prog.take_outputs();
assert!(outputs.last_full_repaint_hint);
assert_eq!(outputs.last_patches.len(), 1);
assert_eq!(outputs.last_patches[0].offset, 0);
assert_eq!(outputs.last_patches[0].cells.len(), 80usize * 24usize);
assert!(outputs.logs.iter().any(|line| {
line.contains(r#""event":"diff_baseline_reset""#)
&& line.contains(r#""from_cols":80"#)
&& line.contains(r#""to_cols":80"#)
}));
assert!(outputs.logs.iter().any(|line| {
line.contains(r#""event":"full_repaint_boundary""#)
&& line.contains(r#""from_rows":24"#)
&& line.contains(r#""to_rows":24"#)
}));
}
#[test]
fn resize_oscillation_forces_full_repaint_without_stale_patch_offsets() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
let _ = prog.take_outputs();
for (w, h) in [(120, 40), (80, 24), (120, 40), (80, 24)] {
prog.resize(w, h);
prog.step().unwrap();
let outputs = prog.take_outputs();
assert!(outputs.last_full_repaint_hint);
let buf = outputs
.last_buffer
.expect("resize render should produce a buffer");
assert_eq!(buf.width(), w);
assert_eq!(buf.height(), h);
let max_cells = usize::from(w) * usize::from(h);
assert_eq!(outputs.last_patches.len(), 1);
let run = &outputs.last_patches[0];
assert_eq!(run.offset, 0);
assert_eq!(run.cells.len(), max_cells);
assert!(run.offset as usize + run.cells.len() <= max_cells);
}
}
#[test]
fn resize_boundary_full_repaint_then_incremental_diff_resume() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
let _ = prog.take_outputs();
prog.resize(100, 30);
prog.step().unwrap();
let after_resize = prog.take_outputs();
assert!(after_resize.last_full_repaint_hint);
prog.push_event(key_event('+'));
prog.step().unwrap();
let after_increment = prog.take_outputs();
assert!(!after_increment.last_full_repaint_hint);
assert!(!after_increment.last_patches.is_empty());
}
#[test]
fn tick_fires_when_rate_elapsed() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.push_event(key_event('+')); prog.step().unwrap();
prog.model_mut().value = 0;
prog.execute_cmd(Cmd::tick(Duration::from_millis(100)));
prog.dirty = false;
prog.advance_time(Duration::from_millis(50));
let result = prog.step().unwrap();
assert_eq!(prog.model().value, 0);
assert!(!result.rendered);
prog.advance_time(Duration::from_millis(60));
let result = prog.step().unwrap();
assert_eq!(prog.model().value, 1); assert!(result.rendered);
}
#[test]
fn tick_uses_deterministic_clock() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.execute_cmd(Cmd::tick(Duration::from_millis(100)));
prog.set_time(Duration::from_millis(200));
prog.step().unwrap();
assert_eq!(prog.model().value, 1);
prog.set_time(Duration::from_millis(350));
prog.step().unwrap();
assert_eq!(prog.model().value, 2);
}
#[test]
fn log_command_captures_to_presenter() {
let mut prog = StepProgram::new(new_counter(5), 80, 24);
prog.init().unwrap();
prog.execute_cmd(Cmd::msg(CounterMsg::LogValue));
let outputs = prog.outputs();
assert_eq!(outputs.logs, vec!["value=5"]);
}
#[test]
fn batch_command_executes_all() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.execute_cmd(Cmd::msg(CounterMsg::BatchIncrement(5)));
assert_eq!(prog.model().value, 5);
}
#[test]
fn task_executes_synchronously() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.execute_cmd(Cmd::msg(CounterMsg::SpawnTask));
assert_eq!(prog.model().value, 1); }
#[test]
fn set_mouse_capture_updates_features() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.execute_cmd(Cmd::set_mouse_capture(true));
assert!(prog.backend().events.features().mouse_capture);
prog.execute_cmd(Cmd::set_mouse_capture(false));
assert!(!prog.backend().events.features().mouse_capture);
}
#[test]
fn rendered_buffer_reflects_model_state() {
let mut prog = StepProgram::new(new_counter(42), 80, 24);
prog.init().unwrap();
let outputs = prog.outputs();
let buf = outputs.last_buffer.as_ref().unwrap();
assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('C'));
assert_eq!(buf.get(7, 0).unwrap().content.as_char(), Some('4'));
assert_eq!(buf.get(8, 0).unwrap().content.as_char(), Some('2'));
}
#[test]
fn subsequent_renders_produce_diffs() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
let outputs = prog.take_outputs();
assert!(outputs.last_full_repaint_hint);
prog.push_event(key_event('+'));
prog.step().unwrap();
let outputs = prog.outputs();
assert!(!outputs.last_full_repaint_hint);
assert!(!outputs.last_patches.is_empty());
let stats = outputs
.last_patch_stats
.expect("patch stats should be captured");
assert!(stats.patch_count >= 1);
assert!(stats.dirty_cells >= 1);
}
#[test]
fn take_outputs_clears_state() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
let outputs = prog.take_outputs();
assert!(outputs.last_buffer.is_some());
let outputs = prog.outputs();
assert!(outputs.last_buffer.is_none());
assert!(outputs.logs.is_empty());
}
#[test]
fn identical_inputs_produce_identical_outputs() {
fn run_scenario() -> (i32, u64, Vec<Option<char>>) {
let mut prog = StepProgram::new(new_counter(0), 20, 1);
prog.init().unwrap();
prog.push_event(key_event('+'));
prog.push_event(key_event('+'));
prog.push_event(key_event('-'));
prog.push_event(key_event('+'));
prog.step().unwrap();
let outputs = prog.outputs();
let buf = outputs.last_buffer.as_ref().unwrap();
let chars: Vec<Option<char>> = (0..20)
.map(|x| buf.get(x, 0).and_then(|c| c.content.as_char()))
.collect();
(prog.model().value, prog.frame_idx(), chars)
}
let (v1, f1, c1) = run_scenario();
let (v2, f2, c2) = run_scenario();
let (v3, f3, c3) = run_scenario();
assert_eq!(v1, v2);
assert_eq!(v2, v3);
assert_eq!(v1, 2); assert_eq!(f1, f2);
assert_eq!(f2, f3);
assert_eq!(c1, c2);
assert_eq!(c2, c3);
}
#[test]
fn with_backend_uses_provided_backend() {
let mut backend = WebBackend::new(100, 50);
backend.clock_mut().set(Duration::from_secs(10));
let prog = StepProgram::with_backend(new_counter(0), backend);
assert_eq!(prog.size(), (100, 50));
}
#[test]
fn multi_step_interaction() {
let mut prog = StepProgram::new(new_counter(0), 80, 24);
prog.init().unwrap();
prog.push_event(key_event('+'));
prog.push_event(key_event('+'));
let r1 = prog.step().unwrap();
assert_eq!(r1.events_processed, 2);
assert!(r1.rendered);
assert_eq!(prog.model().value, 2);
prog.push_event(key_event('-'));
let r2 = prog.step().unwrap();
assert_eq!(r2.events_processed, 1);
assert_eq!(prog.model().value, 1);
let r3 = prog.step().unwrap();
assert_eq!(r3.events_processed, 0);
assert!(!r3.rendered);
assert!(r2.frame_idx > r1.frame_idx);
assert_eq!(r3.frame_idx, r2.frame_idx); }
#[test]
fn periodic_pool_gc_bounds_grapheme_growth() {
let mut prog = StepProgram::new(new_grapheme_churn(), 8, 1);
prog.init().unwrap();
prog.execute_cmd(Cmd::tick(Duration::from_millis(1)));
let mut peak_pool_len = prog.pool().len();
for _ in 0..2000 {
prog.advance_time(Duration::from_millis(1));
let _ = prog.step().unwrap();
peak_pool_len = peak_pool_len.max(prog.pool().len());
}
let final_pool_len = prog.pool().len();
assert!(
peak_pool_len <= (POOL_GC_INTERVAL_FRAMES as usize).saturating_add(2),
"peak grapheme pool length should stay bounded by GC interval (peak={peak_pool_len})"
);
assert!(
final_pool_len <= (POOL_GC_INTERVAL_FRAMES as usize).saturating_add(2),
"final grapheme pool length should stay bounded by GC interval (final={final_pool_len})"
);
}
}