Skip to main content

ftui_web/
step_program.rs

1#![forbid(unsafe_code)]
2
3//! Step-based WASM program runner for FrankenTUI.
4//!
5//! [`StepProgram`] drives an [`ftui_runtime::program::Model`] through
6//! init / event / update / view / present cycles without threads or blocking.
7//! The host (JavaScript) controls the event loop:
8//!
9//! 1. Push events via [`StepProgram::push_event`].
10//! 2. Advance time via [`StepProgram::advance_time`].
11//! 3. Call [`StepProgram::step`] to process one batch of events and render.
12//! 4. Read the rendered buffer via [`StepProgram::take_outputs`].
13//!
14//! # Example
15//!
16//! ```ignore
17//! use ftui_web::step_program::StepProgram;
18//! use ftui_core::event::Event;
19//! use core::time::Duration;
20//!
21//! let mut prog = StepProgram::new(MyModel::default(), 80, 24);
22//! prog.init().unwrap();
23//!
24//! // Host-driven frame loop
25//! prog.push_event(Event::Tick);
26//! prog.advance_time(Duration::from_millis(16));
27//! let result = prog.step().unwrap();
28//!
29//! if result.rendered {
30//!     let outputs = prog.take_outputs();
31//!     // Send outputs.last_buffer to the renderer...
32//! }
33//! ```
34
35use core::time::Duration;
36
37use ftui_backend::{BackendClock, BackendEventSource, BackendPresenter};
38use ftui_core::event::Event;
39use ftui_render::buffer::{Buffer, DoubleBuffer};
40use ftui_render::diff::BufferDiff;
41use ftui_render::frame::Frame;
42use ftui_render::grapheme_pool::GraphemePool;
43use ftui_runtime::program::{Cmd, Model};
44
45use crate::{WebBackend, WebBackendError, WebOutputs};
46
47/// Run grapheme-pool GC every N rendered frames in host-driven WASM mode.
48const POOL_GC_INTERVAL_FRAMES: u64 = 256;
49
50/// Result of a single [`StepProgram::step`] call.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct StepResult {
53    /// Whether the program is still running (false after `Cmd::Quit`).
54    pub running: bool,
55    /// Whether a frame was rendered during this step.
56    pub rendered: bool,
57    /// Number of events processed during this step.
58    pub events_processed: u32,
59    /// Current frame index (monotonically increasing).
60    pub frame_idx: u64,
61}
62
63/// Host-driven, non-blocking program runner for WASM.
64///
65/// Wraps a [`Model`] and a [`WebBackend`], providing a step-based execution
66/// model suitable for `wasm32-unknown-unknown`. No threads, no blocking, no
67/// `std::time::Instant` — all I/O and time are host-driven.
68///
69/// # Lifecycle
70///
71/// 1. [`StepProgram::new`] — create with model and initial terminal size.
72/// 2. [`StepProgram::init`] — call once to initialize the model and render the first frame.
73/// 3. [`StepProgram::step`] — call repeatedly from the host event loop (e.g., `requestAnimationFrame`).
74/// 4. Read outputs after each step via [`StepProgram::take_outputs`].
75pub struct StepProgram<M: Model> {
76    model: M,
77    backend: WebBackend,
78    pool: GraphemePool,
79    running: bool,
80    initialized: bool,
81    dirty: bool,
82    frame_idx: u64,
83    tick_rate: Option<Duration>,
84    last_tick: Duration,
85    width: u16,
86    height: u16,
87    /// Double-buffered render target: O(1) swap instead of O(w*h) clone.
88    dbl_buf: Option<DoubleBuffer>,
89}
90
91impl<M: Model> StepProgram<M> {
92    /// Create a new step program with the given model and initial terminal size.
93    #[must_use]
94    pub fn new(model: M, width: u16, height: u16) -> Self {
95        Self {
96            model,
97            backend: WebBackend::new(width, height),
98            pool: GraphemePool::new(),
99            running: true,
100            initialized: false,
101            dirty: true,
102            frame_idx: 0,
103            tick_rate: None,
104            last_tick: Duration::ZERO,
105            width,
106            height,
107            dbl_buf: None,
108        }
109    }
110
111    /// Create a step program with an existing [`WebBackend`].
112    #[must_use]
113    pub fn with_backend(model: M, mut backend: WebBackend) -> Self {
114        let (width, height) = backend.events_mut().size().unwrap_or((80, 24));
115        Self {
116            model,
117            backend,
118            pool: GraphemePool::new(),
119            running: true,
120            initialized: false,
121            dirty: true,
122            frame_idx: 0,
123            tick_rate: None,
124            last_tick: Duration::ZERO,
125            width,
126            height,
127            dbl_buf: None,
128        }
129    }
130
131    /// Initialize the model and render the first frame.
132    ///
133    /// Must be called exactly once before [`step`](Self::step).
134    /// Calls `Model::init()`, executes returned commands, and presents
135    /// the initial frame.
136    pub fn init(&mut self) -> Result<(), WebBackendError> {
137        assert!(!self.initialized, "StepProgram::init() called twice");
138        self.initialized = true;
139        let cmd = self.model.init();
140        self.execute_cmd(cmd);
141        if self.running {
142            self.render_frame()?;
143        }
144        Ok(())
145    }
146
147    /// Process one batch of pending events, handle ticks, and render if dirty.
148    ///
149    /// This is the main entry point for the host event loop. Call this after
150    /// pushing events and advancing time.
151    ///
152    /// Returns [`StepResult`] describing what happened during the step.
153    pub fn step(&mut self) -> Result<StepResult, WebBackendError> {
154        assert!(self.initialized, "StepProgram::step() called before init()");
155
156        if !self.running {
157            return Ok(StepResult {
158                running: false,
159                rendered: false,
160                events_processed: 0,
161                frame_idx: self.frame_idx,
162            });
163        }
164
165        // 1. Process all pending events.
166        let mut events_processed: u32 = 0;
167        while let Some(event) = self.backend.events.read_event()? {
168            events_processed += 1;
169            self.handle_event(event);
170            if !self.running {
171                break;
172            }
173        }
174
175        // 2. Handle tick if tick_rate is set and enough time has elapsed.
176        if self.running
177            && let Some(rate) = self.tick_rate
178        {
179            let now = self.backend.clock.now_mono();
180            let delta = now.saturating_sub(self.last_tick);
181            let should_tick = if rate.is_zero() { true } else { delta >= rate };
182            if should_tick {
183                // Preserve remainder when running on high-refresh displays:
184                // snapping `last_tick` to the nearest boundary avoids drift and under-ticking.
185                if rate.is_zero() {
186                    self.last_tick = now;
187                } else {
188                    let rem_ns = delta.as_nanos() % rate.as_nanos();
189                    let rem = Duration::from_nanos(rem_ns as u64);
190                    self.last_tick = now.saturating_sub(rem);
191                }
192                let msg = M::Message::from(Event::Tick);
193                let cmd = self.model.update(msg);
194                self.dirty = true;
195                self.execute_cmd(cmd);
196            }
197        }
198
199        // 3. Render if dirty.
200        let rendered = if self.running && self.dirty {
201            self.render_frame()?;
202            true
203        } else {
204            false
205        };
206
207        Ok(StepResult {
208            running: self.running,
209            rendered,
210            events_processed,
211            frame_idx: self.frame_idx,
212        })
213    }
214
215    /// Push a terminal event into the event queue.
216    ///
217    /// Events are processed on the next [`step`](Self::step) call.
218    pub fn push_event(&mut self, event: Event) {
219        // Handle resize events immediately to update internal size tracking.
220        if let Event::Resize { width, height } = &event {
221            self.width = *width;
222            self.height = *height;
223            self.backend.events_mut().set_size(*width, *height);
224        }
225        self.backend.events_mut().push_event(event);
226    }
227
228    /// Advance the deterministic clock by `dt`.
229    pub fn advance_time(&mut self, dt: Duration) {
230        self.backend.clock_mut().advance(dt);
231    }
232
233    /// Set the deterministic clock to an absolute time.
234    pub fn set_time(&mut self, now: Duration) {
235        self.backend.clock_mut().set(now);
236    }
237
238    /// Resize the terminal.
239    ///
240    /// Pushes a `Resize` event and updates the backend size. The resize
241    /// is processed on the next [`step`](Self::step) call.
242    pub fn resize(&mut self, width: u16, height: u16) {
243        self.push_event(Event::Resize { width, height });
244    }
245
246    /// Take the captured outputs (rendered buffer, logs), leaving empty defaults.
247    pub fn take_outputs(&mut self) -> WebOutputs {
248        self.backend.presenter_mut().take_outputs()
249    }
250
251    /// Read the captured outputs without consuming them.
252    pub fn outputs(&self) -> &WebOutputs {
253        self.backend.presenter.outputs()
254    }
255
256    /// Access the model.
257    pub fn model(&self) -> &M {
258        &self.model
259    }
260
261    /// Mutably access the model.
262    pub fn model_mut(&mut self) -> &mut M {
263        &mut self.model
264    }
265
266    /// Access the backend.
267    pub fn backend(&self) -> &WebBackend {
268        &self.backend
269    }
270
271    /// Mutably access the backend.
272    pub fn backend_mut(&mut self) -> &mut WebBackend {
273        &mut self.backend
274    }
275
276    /// Whether the program is still running.
277    pub fn is_running(&self) -> bool {
278        self.running
279    }
280
281    /// Whether the program has been initialized.
282    pub fn is_initialized(&self) -> bool {
283        self.initialized
284    }
285
286    /// Current frame index.
287    pub fn frame_idx(&self) -> u64 {
288        self.frame_idx
289    }
290
291    /// Current terminal dimensions.
292    pub fn size(&self) -> (u16, u16) {
293        (self.width, self.height)
294    }
295
296    /// Current tick rate, if any.
297    pub fn tick_rate(&self) -> Option<Duration> {
298        self.tick_rate
299    }
300
301    /// Access the grapheme pool (needed for deterministic checksumming).
302    pub fn pool(&self) -> &GraphemePool {
303        &self.pool
304    }
305
306    // --- Private helpers ---
307
308    fn handle_event(&mut self, event: Event) {
309        if let Event::Resize { width, height } = &event {
310            self.width = *width;
311            self.height = *height;
312            // Invalidate diff baseline — sizes may differ.
313            self.dbl_buf = None;
314        }
315        let msg = M::Message::from(event);
316        let cmd = self.model.update(msg);
317        self.dirty = true;
318        self.execute_cmd(cmd);
319    }
320
321    fn render_frame(&mut self) -> Result<(), WebBackendError> {
322        // Ensure double buffer exists; first frame triggers allocation.
323        let full_repaint = self.dbl_buf.is_none();
324        if self.dbl_buf.is_none() {
325            self.dbl_buf = Some(DoubleBuffer::new(self.width, self.height));
326        }
327
328        // Swap: previous current becomes the diff baseline, current is cleared.
329        {
330            let dbl = self.dbl_buf.as_mut().unwrap();
331            dbl.swap();
332            dbl.current_mut().clear();
333        }
334
335        // Take the cleared buffer out for Frame construction (avoids per-frame
336        // allocation). The 1×1 placeholder is trivially cheap.
337        let render_buf = std::mem::replace(
338            self.dbl_buf.as_mut().unwrap().current_mut(),
339            Buffer::new(1, 1),
340        );
341        let mut frame = Frame::from_buffer(render_buf, &mut self.pool);
342        self.model.view(&mut frame);
343
344        // Move rendered buffer back into the double buffer's current slot.
345        *self.dbl_buf.as_mut().unwrap().current_mut() = frame.buffer;
346
347        // Compute diff and present.
348        let dbl = self.dbl_buf.as_ref().unwrap();
349        let diff = if full_repaint {
350            None
351        } else {
352            Some(BufferDiff::compute(dbl.previous(), dbl.current()))
353        };
354        let buf = dbl.current().clone();
355
356        self.backend
357            .presenter_mut()
358            .present_ui_owned(buf, diff.as_ref(), full_repaint);
359
360        self.dirty = false;
361        self.frame_idx += 1;
362
363        // Periodic grapheme-pool GC. Destructure to satisfy the borrow
364        // checker: pool and dbl_buf are disjoint fields.
365        if self.frame_idx.is_multiple_of(POOL_GC_INTERVAL_FRAMES) {
366            let Self { dbl_buf, pool, .. } = self;
367            let dbl = dbl_buf.as_ref().unwrap();
368            pool.gc(&[dbl.current(), dbl.previous()]);
369        }
370        Ok(())
371    }
372
373    fn execute_cmd(&mut self, cmd: Cmd<M::Message>) {
374        match cmd {
375            Cmd::None => {}
376            Cmd::Quit => {
377                self.running = false;
378            }
379            Cmd::Msg(m) => {
380                let cmd = self.model.update(m);
381                self.execute_cmd(cmd);
382            }
383            Cmd::Batch(cmds) => {
384                for c in cmds {
385                    self.execute_cmd(c);
386                    if !self.running {
387                        break;
388                    }
389                }
390            }
391            Cmd::Sequence(cmds) => {
392                for c in cmds {
393                    self.execute_cmd(c);
394                    if !self.running {
395                        break;
396                    }
397                }
398            }
399            Cmd::Tick(duration) => {
400                self.tick_rate = Some(duration);
401            }
402            Cmd::Log(text) => {
403                let _ = self.backend.presenter_mut().write_log(&text);
404            }
405            Cmd::Task(_spec, f) => {
406                // WASM has no threads — execute tasks synchronously.
407                let msg = f();
408                let cmd = self.model.update(msg);
409                self.execute_cmd(cmd);
410            }
411            Cmd::SetMouseCapture(enabled) => {
412                let mut features = self.backend.events_mut().features();
413                features.mouse_capture = enabled;
414                let _ = self.backend.events_mut().set_features(features);
415            }
416            Cmd::SaveState | Cmd::RestoreState => {
417                // No persistence in WASM (yet).
418            }
419        }
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
427    use ftui_render::cell::Cell;
428    use ftui_render::drawing::Draw;
429    use pretty_assertions::assert_eq;
430
431    // ---- Test model ----
432
433    struct Counter {
434        value: i32,
435        initialized: bool,
436    }
437
438    #[derive(Debug)]
439    enum CounterMsg {
440        Increment,
441        Decrement,
442        Reset,
443        Quit,
444        LogValue,
445        BatchIncrement(usize),
446        SpawnTask,
447    }
448
449    impl From<Event> for CounterMsg {
450        fn from(event: Event) -> Self {
451            match event {
452                Event::Key(k) if k.code == KeyCode::Char('+') => CounterMsg::Increment,
453                Event::Key(k) if k.code == KeyCode::Char('-') => CounterMsg::Decrement,
454                Event::Key(k) if k.code == KeyCode::Char('r') => CounterMsg::Reset,
455                Event::Key(k) if k.code == KeyCode::Char('q') => CounterMsg::Quit,
456                Event::Tick => CounterMsg::Increment,
457                _ => CounterMsg::Increment,
458            }
459        }
460    }
461
462    impl Model for Counter {
463        type Message = CounterMsg;
464
465        fn init(&mut self) -> Cmd<Self::Message> {
466            self.initialized = true;
467            Cmd::none()
468        }
469
470        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
471            match msg {
472                CounterMsg::Increment => {
473                    self.value += 1;
474                    Cmd::none()
475                }
476                CounterMsg::Decrement => {
477                    self.value -= 1;
478                    Cmd::none()
479                }
480                CounterMsg::Reset => {
481                    self.value = 0;
482                    Cmd::none()
483                }
484                CounterMsg::Quit => Cmd::quit(),
485                CounterMsg::LogValue => Cmd::log(format!("value={}", self.value)),
486                CounterMsg::BatchIncrement(n) => {
487                    let cmds: Vec<_> = (0..n).map(|_| Cmd::msg(CounterMsg::Increment)).collect();
488                    Cmd::batch(cmds)
489                }
490                CounterMsg::SpawnTask => Cmd::task(|| CounterMsg::Increment),
491            }
492        }
493
494        fn view(&self, frame: &mut Frame) {
495            let text = format!("Count: {}", self.value);
496            for (i, c) in text.chars().enumerate() {
497                if (i as u16) < frame.width() {
498                    frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
499                }
500            }
501        }
502    }
503
504    /// Test model that emits a new combining-mark grapheme each frame.
505    ///
506    /// Used to verify periodic grapheme-pool GC in `StepProgram`.
507    struct GraphemeChurn {
508        value: u32,
509    }
510
511    impl Model for GraphemeChurn {
512        type Message = CounterMsg;
513
514        fn init(&mut self) -> Cmd<Self::Message> {
515            Cmd::none()
516        }
517
518        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
519            if let CounterMsg::Increment = msg {
520                self.value = self.value.wrapping_add(1);
521            }
522            Cmd::none()
523        }
524
525        fn view(&self, frame: &mut Frame) {
526            let base = char::from_u32(0x4e00 + (self.value % 2048)).unwrap_or('字');
527            let text = format!("{base}\u{0301}");
528            frame.print_text(0, 0, &text, Cell::default());
529        }
530    }
531
532    fn key_event(c: char) -> Event {
533        Event::Key(KeyEvent {
534            code: KeyCode::Char(c),
535            modifiers: Modifiers::empty(),
536            kind: KeyEventKind::Press,
537        })
538    }
539
540    fn new_counter(value: i32) -> Counter {
541        Counter {
542            value,
543            initialized: false,
544        }
545    }
546
547    fn new_grapheme_churn() -> GraphemeChurn {
548        GraphemeChurn { value: 0 }
549    }
550
551    // ---- Construction and lifecycle ----
552
553    #[test]
554    fn new_creates_uninitialized_program() {
555        let prog = StepProgram::new(new_counter(0), 80, 24);
556        assert!(!prog.is_initialized());
557        assert!(prog.is_running());
558        assert_eq!(prog.size(), (80, 24));
559        assert_eq!(prog.frame_idx(), 0);
560        assert!(prog.tick_rate().is_none());
561    }
562
563    #[test]
564    fn init_initializes_model_and_renders_first_frame() {
565        let mut prog = StepProgram::new(new_counter(0), 80, 24);
566        prog.init().unwrap();
567
568        assert!(prog.is_initialized());
569        assert!(prog.model().initialized);
570        assert_eq!(prog.frame_idx(), 1); // First frame rendered.
571
572        let outputs = prog.outputs();
573        assert!(outputs.last_buffer.is_some());
574        assert!(outputs.last_full_repaint_hint); // First frame is full repaint.
575        assert_eq!(outputs.last_patches.len(), 1);
576        let stats = outputs
577            .last_patch_stats
578            .expect("patch stats should be captured");
579        assert_eq!(stats.patch_count, 1);
580        assert_eq!(stats.dirty_cells, 80 * 24);
581    }
582
583    #[test]
584    #[should_panic(expected = "init() called twice")]
585    fn double_init_panics() {
586        let mut prog = StepProgram::new(new_counter(0), 80, 24);
587        prog.init().unwrap();
588        prog.init().unwrap();
589    }
590
591    #[test]
592    #[should_panic(expected = "step() called before init()")]
593    fn step_before_init_panics() {
594        let mut prog = StepProgram::new(new_counter(0), 80, 24);
595        let _ = prog.step();
596    }
597
598    // ---- Event processing ----
599
600    #[test]
601    fn step_processes_pushed_events() {
602        let mut prog = StepProgram::new(new_counter(0), 80, 24);
603        prog.init().unwrap();
604
605        prog.push_event(key_event('+'));
606        prog.push_event(key_event('+'));
607        prog.push_event(key_event('+'));
608        let result = prog.step().unwrap();
609
610        assert!(result.running);
611        assert!(result.rendered);
612        assert_eq!(result.events_processed, 3);
613        assert_eq!(prog.model().value, 3);
614    }
615
616    #[test]
617    fn step_with_no_events_does_not_render() {
618        let mut prog = StepProgram::new(new_counter(0), 80, 24);
619        prog.init().unwrap();
620
621        // Take initial outputs.
622        prog.take_outputs();
623
624        let result = prog.step().unwrap();
625        assert!(result.running);
626        assert!(!result.rendered);
627        assert_eq!(result.events_processed, 0);
628    }
629
630    #[test]
631    fn quit_event_stops_program() {
632        let mut prog = StepProgram::new(new_counter(0), 80, 24);
633        prog.init().unwrap();
634
635        prog.push_event(key_event('+'));
636        prog.push_event(key_event('q'));
637        prog.push_event(key_event('+')); // Should not be processed.
638        let result = prog.step().unwrap();
639
640        assert!(!result.running);
641        assert!(!prog.is_running());
642        assert_eq!(prog.model().value, 1); // Only first '+' processed.
643    }
644
645    #[test]
646    fn step_after_quit_returns_immediately() {
647        let mut prog = StepProgram::new(new_counter(0), 80, 24);
648        prog.init().unwrap();
649
650        prog.push_event(key_event('q'));
651        prog.step().unwrap();
652
653        // Further steps do nothing.
654        prog.push_event(key_event('+'));
655        let result = prog.step().unwrap();
656        assert!(!result.running);
657        assert!(!result.rendered);
658        assert_eq!(result.events_processed, 0);
659        assert_eq!(prog.model().value, 0);
660    }
661
662    // ---- Resize ----
663
664    #[test]
665    fn resize_updates_dimensions() {
666        let mut prog = StepProgram::new(new_counter(0), 80, 24);
667        prog.init().unwrap();
668
669        prog.resize(120, 40);
670        prog.step().unwrap();
671
672        assert_eq!(prog.size(), (120, 40));
673    }
674
675    #[test]
676    fn resize_produces_correctly_sized_buffer() {
677        let mut prog = StepProgram::new(new_counter(42), 80, 24);
678        prog.init().unwrap();
679
680        prog.resize(40, 10);
681        prog.step().unwrap();
682
683        let outputs = prog.outputs();
684        let buf = outputs.last_buffer.as_ref().unwrap();
685        assert_eq!(buf.width(), 40);
686        assert_eq!(buf.height(), 10);
687    }
688
689    // ---- Tick handling ----
690
691    #[test]
692    fn tick_fires_when_rate_elapsed() {
693        let mut prog = StepProgram::new(new_counter(0), 80, 24);
694        prog.init().unwrap();
695
696        // Schedule tick at 100ms intervals.
697        prog.push_event(key_event('+')); // Will map to Increment, but we use send for ScheduleTick.
698        prog.step().unwrap();
699
700        // Manually set tick rate (since our test model doesn't emit ScheduleTick from events).
701        prog.model_mut().value = 0;
702        // Directly use the Cmd to schedule ticks through a dedicated message.
703        prog.execute_cmd(Cmd::tick(Duration::from_millis(100)));
704        prog.dirty = false; // Reset dirty so we can detect tick-triggered renders.
705
706        // Advance less than tick rate — no tick.
707        prog.advance_time(Duration::from_millis(50));
708        let result = prog.step().unwrap();
709        assert_eq!(prog.model().value, 0);
710        assert!(!result.rendered);
711
712        // Advance past tick rate — tick fires.
713        prog.advance_time(Duration::from_millis(60));
714        let result = prog.step().unwrap();
715        assert_eq!(prog.model().value, 1); // Tick -> Increment.
716        assert!(result.rendered);
717    }
718
719    #[test]
720    fn tick_uses_deterministic_clock() {
721        let mut prog = StepProgram::new(new_counter(0), 80, 24);
722        prog.init().unwrap();
723        prog.execute_cmd(Cmd::tick(Duration::from_millis(100)));
724
725        // Set absolute time to trigger tick.
726        prog.set_time(Duration::from_millis(200));
727        prog.step().unwrap();
728        assert_eq!(prog.model().value, 1);
729
730        // Advance to next tick boundary.
731        prog.set_time(Duration::from_millis(350));
732        prog.step().unwrap();
733        assert_eq!(prog.model().value, 2);
734    }
735
736    // ---- Command execution ----
737
738    #[test]
739    fn log_command_captures_to_presenter() {
740        let mut prog = StepProgram::new(new_counter(5), 80, 24);
741        prog.init().unwrap();
742
743        // LogValue emits Cmd::Log("value=5").
744        prog.execute_cmd(Cmd::msg(CounterMsg::LogValue));
745
746        let outputs = prog.outputs();
747        assert_eq!(outputs.logs, vec!["value=5"]);
748    }
749
750    #[test]
751    fn batch_command_executes_all() {
752        let mut prog = StepProgram::new(new_counter(0), 80, 24);
753        prog.init().unwrap();
754
755        prog.execute_cmd(Cmd::msg(CounterMsg::BatchIncrement(5)));
756        assert_eq!(prog.model().value, 5);
757    }
758
759    #[test]
760    fn task_executes_synchronously() {
761        let mut prog = StepProgram::new(new_counter(0), 80, 24);
762        prog.init().unwrap();
763
764        prog.execute_cmd(Cmd::msg(CounterMsg::SpawnTask));
765        assert_eq!(prog.model().value, 1); // Task returns Increment.
766    }
767
768    #[test]
769    fn set_mouse_capture_updates_features() {
770        let mut prog = StepProgram::new(new_counter(0), 80, 24);
771        prog.init().unwrap();
772
773        prog.execute_cmd(Cmd::set_mouse_capture(true));
774        assert!(prog.backend().events.features().mouse_capture);
775
776        prog.execute_cmd(Cmd::set_mouse_capture(false));
777        assert!(!prog.backend().events.features().mouse_capture);
778    }
779
780    // ---- Rendering ----
781
782    #[test]
783    fn rendered_buffer_reflects_model_state() {
784        let mut prog = StepProgram::new(new_counter(42), 80, 24);
785        prog.init().unwrap();
786
787        let outputs = prog.outputs();
788        let buf = outputs.last_buffer.as_ref().unwrap();
789
790        // "Count: 42"
791        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('C'));
792        assert_eq!(buf.get(7, 0).unwrap().content.as_char(), Some('4'));
793        assert_eq!(buf.get(8, 0).unwrap().content.as_char(), Some('2'));
794    }
795
796    #[test]
797    fn subsequent_renders_produce_diffs() {
798        let mut prog = StepProgram::new(new_counter(0), 80, 24);
799        prog.init().unwrap();
800
801        // First frame is full repaint.
802        let outputs = prog.take_outputs();
803        assert!(outputs.last_full_repaint_hint);
804
805        // Second frame after an event should not be full repaint.
806        prog.push_event(key_event('+'));
807        prog.step().unwrap();
808
809        let outputs = prog.outputs();
810        assert!(!outputs.last_full_repaint_hint);
811        assert!(!outputs.last_patches.is_empty());
812        let stats = outputs
813            .last_patch_stats
814            .expect("patch stats should be captured");
815        assert!(stats.patch_count >= 1);
816        assert!(stats.dirty_cells >= 1);
817    }
818
819    #[test]
820    fn take_outputs_clears_state() {
821        let mut prog = StepProgram::new(new_counter(0), 80, 24);
822        prog.init().unwrap();
823
824        let outputs = prog.take_outputs();
825        assert!(outputs.last_buffer.is_some());
826
827        // After take, outputs should be empty.
828        let outputs = prog.outputs();
829        assert!(outputs.last_buffer.is_none());
830        assert!(outputs.logs.is_empty());
831    }
832
833    // ---- Determinism ----
834
835    #[test]
836    fn identical_inputs_produce_identical_outputs() {
837        fn run_scenario() -> (i32, u64, Vec<Option<char>>) {
838            let mut prog = StepProgram::new(new_counter(0), 20, 1);
839            prog.init().unwrap();
840
841            prog.push_event(key_event('+'));
842            prog.push_event(key_event('+'));
843            prog.push_event(key_event('-'));
844            prog.push_event(key_event('+'));
845            prog.step().unwrap();
846
847            let outputs = prog.outputs();
848            let buf = outputs.last_buffer.as_ref().unwrap();
849            let chars: Vec<Option<char>> = (0..20)
850                .map(|x| buf.get(x, 0).and_then(|c| c.content.as_char()))
851                .collect();
852
853            (prog.model().value, prog.frame_idx(), chars)
854        }
855
856        let (v1, f1, c1) = run_scenario();
857        let (v2, f2, c2) = run_scenario();
858        let (v3, f3, c3) = run_scenario();
859
860        assert_eq!(v1, v2);
861        assert_eq!(v2, v3);
862        assert_eq!(v1, 2); // +1+1-1+1 = 2
863        assert_eq!(f1, f2);
864        assert_eq!(f2, f3);
865        assert_eq!(c1, c2);
866        assert_eq!(c2, c3);
867    }
868
869    // ---- with_backend constructor ----
870
871    #[test]
872    fn with_backend_uses_provided_backend() {
873        let mut backend = WebBackend::new(100, 50);
874        backend.clock_mut().set(Duration::from_secs(10));
875
876        let prog = StepProgram::with_backend(new_counter(0), backend);
877        assert_eq!(prog.size(), (100, 50));
878    }
879
880    // ---- Multi-step scenario ----
881
882    #[test]
883    fn multi_step_interaction() {
884        let mut prog = StepProgram::new(new_counter(0), 80, 24);
885        prog.init().unwrap();
886
887        // Frame 1: increment twice.
888        prog.push_event(key_event('+'));
889        prog.push_event(key_event('+'));
890        let r1 = prog.step().unwrap();
891        assert_eq!(r1.events_processed, 2);
892        assert!(r1.rendered);
893        assert_eq!(prog.model().value, 2);
894
895        // Frame 2: decrement once.
896        prog.push_event(key_event('-'));
897        let r2 = prog.step().unwrap();
898        assert_eq!(r2.events_processed, 1);
899        assert_eq!(prog.model().value, 1);
900
901        // Frame 3: no events.
902        let r3 = prog.step().unwrap();
903        assert_eq!(r3.events_processed, 0);
904        assert!(!r3.rendered);
905
906        // Frame indices are monotonic.
907        assert!(r2.frame_idx > r1.frame_idx);
908        assert_eq!(r3.frame_idx, r2.frame_idx); // No render, same index.
909    }
910
911    #[test]
912    fn periodic_pool_gc_bounds_grapheme_growth() {
913        let mut prog = StepProgram::new(new_grapheme_churn(), 8, 1);
914        prog.init().unwrap();
915        prog.execute_cmd(Cmd::tick(Duration::from_millis(1)));
916
917        let mut peak_pool_len = prog.pool().len();
918        for _ in 0..2000 {
919            prog.advance_time(Duration::from_millis(1));
920            let _ = prog.step().unwrap();
921            peak_pool_len = peak_pool_len.max(prog.pool().len());
922        }
923
924        let final_pool_len = prog.pool().len();
925        assert!(
926            peak_pool_len <= (POOL_GC_INTERVAL_FRAMES as usize).saturating_add(2),
927            "peak grapheme pool length should stay bounded by GC interval (peak={peak_pool_len})"
928        );
929        assert!(
930            final_pool_len <= (POOL_GC_INTERVAL_FRAMES as usize).saturating_add(2),
931            "final grapheme pool length should stay bounded by GC interval (final={final_pool_len})"
932        );
933    }
934}