1#![forbid(unsafe_code)]
2
3use 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
47const POOL_GC_INTERVAL_FRAMES: u64 = 256;
49const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct StepResult {
72 pub running: bool,
74 pub rendered: bool,
76 pub events_processed: u32,
78 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
90pub 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 dbl_buf: Option<DoubleBuffer>,
116 pending_geometry_transition: Option<GeometryTransition>,
118}
119
120impl<M: Model> StepProgram<M> {
121 #[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 #[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 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 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 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 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 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 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 pub fn push_event(&mut self, event: Event) {
253 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 pub fn advance_time(&mut self, dt: Duration) {
268 self.backend.clock_mut().advance(dt);
269 }
270
271 pub fn set_time(&mut self, now: Duration) {
273 self.backend.clock_mut().set(now);
274 }
275
276 pub fn resize(&mut self, width: u16, height: u16) {
281 self.push_event(Event::Resize { width, height });
282 }
283
284 pub fn take_outputs(&mut self) -> WebOutputs {
286 self.backend.presenter_mut().take_outputs()
287 }
288
289 pub fn outputs(&self) -> &WebOutputs {
291 self.backend.presenter.outputs()
292 }
293
294 pub fn model(&self) -> &M {
296 &self.model
297 }
298
299 pub fn model_mut(&mut self) -> &mut M {
301 &mut self.model
302 }
303
304 pub fn backend(&self) -> &WebBackend {
306 &self.backend
307 }
308
309 pub fn backend_mut(&mut self) -> &mut WebBackend {
311 &mut self.backend
312 }
313
314 pub fn is_running(&self) -> bool {
316 self.running
317 }
318
319 pub fn is_initialized(&self) -> bool {
321 self.initialized
322 }
323
324 pub fn frame_idx(&self) -> u64 {
326 self.frame_idx
327 }
328
329 pub fn size(&self) -> (u16, u16) {
331 (self.width, self.height)
332 }
333
334 pub fn tick_rate(&self) -> Option<Duration> {
336 self.tick_rate
337 }
338
339 pub fn pool(&self) -> &GraphemePool {
341 &self.pool
342 }
343
344 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 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 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 {
382 let dbl = self.dbl_buf.as_mut().unwrap();
383 dbl.swap();
384 dbl.current_mut().clear();
385 }
386
387 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 *self.dbl_buf.as_mut().unwrap().current_mut() = frame.buffer;
398
399 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 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 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 }
497 Cmd::SetTickStrategy(_) => {
498 }
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 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 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 #[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); let outputs = prog.outputs();
667 assert!(outputs.last_buffer.is_some());
668 assert!(outputs.last_full_repaint_hint); 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 #[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 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('+')); let result = prog.step().unwrap();
733
734 assert!(!result.running);
735 assert!(!prog.is_running());
736 assert_eq!(prog.model().value, 1); }
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 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 #[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(); 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(); 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 #[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 prog.push_event(key_event('+')); prog.step().unwrap();
910
911 prog.model_mut().value = 0;
913 prog.execute_cmd(Cmd::tick(Duration::from_millis(100)));
915 prog.dirty = false; 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 prog.advance_time(Duration::from_millis(60));
925 let result = prog.step().unwrap();
926 assert_eq!(prog.model().value, 1); 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 prog.set_time(Duration::from_millis(200));
938 prog.step().unwrap();
939 assert_eq!(prog.model().value, 1);
940
941 prog.set_time(Duration::from_millis(350));
943 prog.step().unwrap();
944 assert_eq!(prog.model().value, 2);
945 }
946
947 #[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 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); }
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 #[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 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 let outputs = prog.take_outputs();
1014 assert!(outputs.last_full_repaint_hint);
1015
1016 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 let outputs = prog.outputs();
1040 assert!(outputs.last_buffer.is_none());
1041 assert!(outputs.logs.is_empty());
1042 }
1043
1044 #[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); assert_eq!(f1, f2);
1075 assert_eq!(f2, f3);
1076 assert_eq!(c1, c2);
1077 assert_eq!(c2, c3);
1078 }
1079
1080 #[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 #[test]
1094 fn multi_step_interaction() {
1095 let mut prog = StepProgram::new(new_counter(0), 80, 24);
1096 prog.init().unwrap();
1097
1098 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 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 let r3 = prog.step().unwrap();
1114 assert_eq!(r3.events_processed, 0);
1115 assert!(!r3.rendered);
1116
1117 assert!(r2.frame_idx > r1.frame_idx);
1119 assert_eq!(r3.frame_idx, r2.frame_idx); }
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}