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