Skip to main content

ftui_runtime/
simulator.rs

1#![forbid(unsafe_code)]
2
3//! Deterministic program simulator for testing.
4//!
5//! `ProgramSimulator` runs a [`Model`] without a real terminal, enabling
6//! deterministic snapshot testing, event injection, and frame capture.
7//!
8//! # Example
9//!
10//! ```ignore
11//! use ftui_runtime::simulator::ProgramSimulator;
12//!
13//! let mut sim = ProgramSimulator::new(Counter { value: 0 });
14//! sim.init();
15//! sim.send(Msg::Increment);
16//! assert_eq!(sim.model().value, 1);
17//!
18//! let buf = sim.capture_frame(80, 24);
19//! // Assert on buffer contents...
20//! ```
21
22use crate::program::{Cmd, Model};
23use crate::state_persistence::StateRegistry;
24use ftui_core::event::Event;
25use ftui_render::buffer::Buffer;
26use ftui_render::frame::Frame;
27use ftui_render::grapheme_pool::GraphemePool;
28use std::sync::Arc;
29use std::time::Duration;
30
31/// Record of a command that was executed during simulation.
32#[derive(Debug, Clone)]
33pub enum CmdRecord {
34    /// No-op command.
35    None,
36    /// Quit command.
37    Quit,
38    /// Message sent to model (not stored, just noted).
39    Msg,
40    /// Batch of commands.
41    Batch(usize),
42    /// Sequence of commands.
43    Sequence(usize),
44    /// Tick scheduled.
45    Tick(Duration),
46    /// Log message emitted.
47    Log(String),
48    /// Background task executed synchronously.
49    Task,
50    /// Mouse capture toggle (no-op in simulator).
51    MouseCapture(bool),
52}
53
54/// Deterministic simulator for [`Model`] testing.
55///
56/// Runs model logic without any terminal or IO dependencies. Events can be
57/// injected, messages sent directly, and frames captured for snapshot testing.
58pub struct ProgramSimulator<M: Model> {
59    /// The application model.
60    model: M,
61    /// Grapheme pool for frame creation.
62    pool: GraphemePool,
63    /// Captured frame buffers.
64    frames: Vec<Buffer>,
65    /// Record of all executed commands.
66    command_log: Vec<CmdRecord>,
67    /// Whether the simulated program is still running.
68    running: bool,
69    /// Current tick rate (if any).
70    tick_rate: Option<Duration>,
71    /// Log messages emitted via Cmd::Log.
72    logs: Vec<String>,
73    /// Optional state registry for persistence integration.
74    state_registry: Option<Arc<StateRegistry>>,
75}
76
77impl<M: Model> ProgramSimulator<M> {
78    /// Create a new simulator with the given model.
79    ///
80    /// The model is not initialized until [`init`](Self::init) is called.
81    pub fn new(model: M) -> Self {
82        Self {
83            model,
84            pool: GraphemePool::new(),
85            frames: Vec::new(),
86            command_log: Vec::new(),
87            running: true,
88            tick_rate: None,
89            logs: Vec::new(),
90            state_registry: None,
91        }
92    }
93
94    /// Create a new simulator with the given model and persistence registry.
95    ///
96    /// When provided, `Cmd::SaveState`/`Cmd::RestoreState` will flush/load
97    /// through the registry, mirroring runtime behavior.
98    pub fn with_registry(model: M, registry: Arc<StateRegistry>) -> Self {
99        let mut sim = Self::new(model);
100        sim.state_registry = Some(registry);
101        sim
102    }
103
104    /// Initialize the model by calling `Model::init()` and executing returned commands.
105    ///
106    /// Should be called once before injecting events or capturing frames.
107    pub fn init(&mut self) {
108        let cmd = self.model.init();
109        self.execute_cmd(cmd);
110    }
111
112    /// Inject terminal events into the model.
113    ///
114    /// Each event is converted to a message via `From<Event>` and dispatched
115    /// through `Model::update()`. Commands returned from update are executed.
116    pub fn inject_events(&mut self, events: &[Event]) {
117        for event in events {
118            if !self.running {
119                break;
120            }
121            let msg = M::Message::from(event.clone());
122            let cmd = self.model.update(msg);
123            self.execute_cmd(cmd);
124        }
125    }
126
127    /// Inject a single terminal event into the model.
128    ///
129    /// The event is converted to a message via `From<Event>` and dispatched
130    /// through `Model::update()`. Commands returned from update are executed.
131    pub fn inject_event(&mut self, event: Event) {
132        self.inject_events(&[event]);
133    }
134
135    /// Send a specific message to the model.
136    ///
137    /// The message is dispatched through `Model::update()` and returned
138    /// commands are executed.
139    pub fn send(&mut self, msg: M::Message) {
140        if !self.running {
141            return;
142        }
143        let cmd = self.model.update(msg);
144        self.execute_cmd(cmd);
145    }
146
147    /// Capture the current frame at the given dimensions.
148    ///
149    /// Calls `Model::view()` to render into a fresh buffer and stores the
150    /// result. Returns a reference to the captured buffer.
151    pub fn capture_frame(&mut self, width: u16, height: u16) -> &Buffer {
152        let mut frame = Frame::new(width, height, &mut self.pool);
153        self.model.view(&mut frame);
154        self.frames.push(frame.buffer);
155        self.frames.last().expect("frame just pushed")
156    }
157
158    /// Get all captured frame buffers.
159    pub fn frames(&self) -> &[Buffer] {
160        &self.frames
161    }
162
163    /// Get the most recently captured frame buffer, if any.
164    pub fn last_frame(&self) -> Option<&Buffer> {
165        self.frames.last()
166    }
167
168    /// Get the number of captured frames.
169    pub fn frame_count(&self) -> usize {
170        self.frames.len()
171    }
172
173    /// Get a reference to the model.
174    #[inline]
175    pub fn model(&self) -> &M {
176        &self.model
177    }
178
179    /// Get a mutable reference to the model.
180    #[inline]
181    pub fn model_mut(&mut self) -> &mut M {
182        &mut self.model
183    }
184
185    /// Access the simulator grapheme pool used to render captured frames.
186    #[inline]
187    pub fn pool(&self) -> &GraphemePool {
188        &self.pool
189    }
190
191    /// Check if the simulated program is still running.
192    ///
193    /// Returns `false` after a `Cmd::Quit` has been executed.
194    #[inline]
195    pub fn is_running(&self) -> bool {
196        self.running
197    }
198
199    /// Get the current tick rate (if any).
200    #[inline]
201    pub fn tick_rate(&self) -> Option<Duration> {
202        self.tick_rate
203    }
204
205    /// Get all log messages emitted via `Cmd::Log`.
206    #[inline]
207    pub fn logs(&self) -> &[String] {
208        &self.logs
209    }
210
211    /// Get the command execution log.
212    #[inline]
213    pub fn command_log(&self) -> &[CmdRecord] {
214        &self.command_log
215    }
216
217    /// Clear all captured frames.
218    pub fn clear_frames(&mut self) {
219        self.frames.clear();
220    }
221
222    /// Clear all logs.
223    pub fn clear_logs(&mut self) {
224        self.logs.clear();
225    }
226
227    /// Execute a command without IO.
228    ///
229    /// Cmd::Msg recurses through update; Cmd::Log records the text;
230    /// IO-dependent operations are simulated (no real terminal writes).
231    /// Save/Restore use the configured registry when present.
232    fn execute_cmd(&mut self, cmd: Cmd<M::Message>) {
233        match cmd {
234            Cmd::None => {
235                self.command_log.push(CmdRecord::None);
236            }
237            Cmd::Quit => {
238                self.running = false;
239                self.command_log.push(CmdRecord::Quit);
240            }
241            Cmd::Msg(m) => {
242                self.command_log.push(CmdRecord::Msg);
243                let cmd = self.model.update(m);
244                self.execute_cmd(cmd);
245            }
246            Cmd::Batch(cmds) => {
247                let count = cmds.len();
248                self.command_log.push(CmdRecord::Batch(count));
249                for c in cmds {
250                    self.execute_cmd(c);
251                    if !self.running {
252                        break;
253                    }
254                }
255            }
256            Cmd::Sequence(cmds) => {
257                let count = cmds.len();
258                self.command_log.push(CmdRecord::Sequence(count));
259                for c in cmds {
260                    self.execute_cmd(c);
261                    if !self.running {
262                        break;
263                    }
264                }
265            }
266            Cmd::Tick(duration) => {
267                self.tick_rate = Some(duration);
268                self.command_log.push(CmdRecord::Tick(duration));
269            }
270            Cmd::Log(text) => {
271                self.command_log.push(CmdRecord::Log(text.clone()));
272                self.logs.push(text);
273            }
274            Cmd::SetMouseCapture(enabled) => {
275                self.command_log.push(CmdRecord::MouseCapture(enabled));
276            }
277            Cmd::Task(_, f) => {
278                self.command_log.push(CmdRecord::Task);
279                let msg = f();
280                let cmd = self.model.update(msg);
281                self.execute_cmd(cmd);
282            }
283            Cmd::SaveState => {
284                if let Some(registry) = &self.state_registry {
285                    let _ = registry.flush();
286                }
287            }
288            Cmd::RestoreState => {
289                if let Some(registry) = &self.state_registry {
290                    let _ = registry.load();
291                }
292            }
293            Cmd::SetTickStrategy(_) => {
294                // No-op in simulator mode for now.
295            }
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
304    use std::cell::RefCell;
305    use std::sync::Arc;
306
307    // ---------- Test model ----------
308
309    struct Counter {
310        value: i32,
311        initialized: bool,
312    }
313
314    #[derive(Debug)]
315    enum CounterMsg {
316        Increment,
317        Decrement,
318        Reset,
319        Quit,
320        LogValue,
321        BatchIncrement(usize),
322    }
323
324    impl From<Event> for CounterMsg {
325        fn from(event: Event) -> Self {
326            match event {
327                Event::Key(k) if k.code == KeyCode::Char('+') => CounterMsg::Increment,
328                Event::Key(k) if k.code == KeyCode::Char('-') => CounterMsg::Decrement,
329                Event::Key(k) if k.code == KeyCode::Char('r') => CounterMsg::Reset,
330                Event::Key(k) if k.code == KeyCode::Char('q') => CounterMsg::Quit,
331                _ => CounterMsg::Increment,
332            }
333        }
334    }
335
336    impl Model for Counter {
337        type Message = CounterMsg;
338
339        fn init(&mut self) -> Cmd<Self::Message> {
340            self.initialized = true;
341            Cmd::none()
342        }
343
344        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
345            match msg {
346                CounterMsg::Increment => {
347                    self.value += 1;
348                    Cmd::none()
349                }
350                CounterMsg::Decrement => {
351                    self.value -= 1;
352                    Cmd::none()
353                }
354                CounterMsg::Reset => {
355                    self.value = 0;
356                    Cmd::none()
357                }
358                CounterMsg::Quit => Cmd::quit(),
359                CounterMsg::LogValue => Cmd::log(format!("value={}", self.value)),
360                CounterMsg::BatchIncrement(n) => {
361                    let cmds: Vec<_> = (0..n).map(|_| Cmd::msg(CounterMsg::Increment)).collect();
362                    Cmd::batch(cmds)
363                }
364            }
365        }
366
367        fn view(&self, frame: &mut Frame) {
368            // Render counter value as text in the first row
369            let text = format!("Count: {}", self.value);
370            for (i, c) in text.chars().enumerate() {
371                if (i as u16) < frame.width() {
372                    use ftui_render::cell::Cell;
373                    frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
374                }
375            }
376        }
377    }
378
379    fn key_event(c: char) -> Event {
380        Event::Key(KeyEvent {
381            code: KeyCode::Char(c),
382            modifiers: Modifiers::empty(),
383            kind: KeyEventKind::Press,
384        })
385    }
386
387    fn resize_event(width: u16, height: u16) -> Event {
388        Event::Resize { width, height }
389    }
390
391    #[derive(Default)]
392    struct ResizeTracker {
393        last: Option<(u16, u16)>,
394        history: Vec<(u16, u16)>,
395    }
396
397    #[derive(Debug, Clone, Copy)]
398    enum ResizeMsg {
399        Resize(u16, u16),
400        Quit,
401        Noop,
402    }
403
404    impl From<Event> for ResizeMsg {
405        fn from(event: Event) -> Self {
406            match event {
407                Event::Resize { width, height } => Self::Resize(width, height),
408                Event::Key(k) if k.code == KeyCode::Char('q') => Self::Quit,
409                _ => Self::Noop,
410            }
411        }
412    }
413
414    impl Model for ResizeTracker {
415        type Message = ResizeMsg;
416
417        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
418            match msg {
419                ResizeMsg::Resize(width, height) => {
420                    self.last = Some((width, height));
421                    self.history.push((width, height));
422                    Cmd::none()
423                }
424                ResizeMsg::Quit => Cmd::quit(),
425                ResizeMsg::Noop => Cmd::none(),
426            }
427        }
428
429        fn view(&self, _frame: &mut Frame) {}
430    }
431
432    #[derive(Default)]
433    struct PersistModel;
434
435    #[derive(Debug, Clone, Copy)]
436    enum PersistMsg {
437        Save,
438        Restore,
439        Noop,
440    }
441
442    impl From<Event> for PersistMsg {
443        fn from(event: Event) -> Self {
444            match event {
445                Event::Key(k) if k.code == KeyCode::Char('s') => Self::Save,
446                Event::Key(k) if k.code == KeyCode::Char('r') => Self::Restore,
447                _ => Self::Noop,
448            }
449        }
450    }
451
452    impl Model for PersistModel {
453        type Message = PersistMsg;
454
455        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
456            match msg {
457                PersistMsg::Save => Cmd::save_state(),
458                PersistMsg::Restore => Cmd::restore_state(),
459                PersistMsg::Noop => Cmd::none(),
460            }
461        }
462
463        fn view(&self, _frame: &mut Frame) {}
464    }
465
466    // ---------- Tests ----------
467
468    #[test]
469    fn new_simulator() {
470        let sim = ProgramSimulator::new(Counter {
471            value: 0,
472            initialized: false,
473        });
474        assert!(sim.is_running());
475        assert_eq!(sim.model().value, 0);
476        assert!(!sim.model().initialized);
477        assert_eq!(sim.frame_count(), 0);
478        assert!(sim.logs().is_empty());
479    }
480
481    #[test]
482    fn init_calls_model_init() {
483        let mut sim = ProgramSimulator::new(Counter {
484            value: 0,
485            initialized: false,
486        });
487        sim.init();
488        assert!(sim.model().initialized);
489    }
490
491    #[test]
492    fn inject_events_processes_all() {
493        let mut sim = ProgramSimulator::new(Counter {
494            value: 0,
495            initialized: false,
496        });
497        sim.init();
498
499        let events = vec![key_event('+'), key_event('+'), key_event('+')];
500        sim.inject_events(&events);
501
502        assert_eq!(sim.model().value, 3);
503    }
504
505    #[test]
506    fn inject_events_stops_on_quit() {
507        let mut sim = ProgramSimulator::new(Counter {
508            value: 0,
509            initialized: false,
510        });
511        sim.init();
512
513        // Quit in the middle - subsequent events should be ignored
514        let events = vec![key_event('+'), key_event('q'), key_event('+')];
515        sim.inject_events(&events);
516
517        assert_eq!(sim.model().value, 1);
518        assert!(!sim.is_running());
519    }
520
521    #[test]
522    fn save_state_flushes_registry() {
523        use crate::state_persistence::StateRegistry;
524
525        let registry = Arc::new(StateRegistry::in_memory());
526        registry.set("viewer", 1, vec![1, 2, 3]);
527        assert!(registry.is_dirty());
528
529        let mut sim = ProgramSimulator::with_registry(PersistModel, Arc::clone(&registry));
530        sim.send(PersistMsg::Save);
531
532        assert!(!registry.is_dirty());
533        let stored = registry.get("viewer").expect("entry present");
534        assert_eq!(stored.version, 1);
535        assert_eq!(stored.data, vec![1, 2, 3]);
536    }
537
538    #[test]
539    fn restore_state_round_trips_cache() {
540        use crate::state_persistence::StateRegistry;
541
542        let registry = Arc::new(StateRegistry::in_memory());
543        registry.set("viewer", 7, vec![9, 8, 7]);
544
545        let mut sim = ProgramSimulator::with_registry(PersistModel, Arc::clone(&registry));
546        sim.send(PersistMsg::Save);
547
548        let removed = registry.remove("viewer");
549        assert!(removed.is_some());
550        assert!(registry.get("viewer").is_none());
551
552        sim.send(PersistMsg::Restore);
553        let restored = registry.get("viewer").expect("restored entry");
554        assert_eq!(restored.version, 7);
555        assert_eq!(restored.data, vec![9, 8, 7]);
556    }
557
558    #[test]
559    fn resize_events_apply_in_order() {
560        let mut sim = ProgramSimulator::new(ResizeTracker::default());
561        sim.init();
562
563        let events = vec![
564            resize_event(80, 24),
565            resize_event(100, 40),
566            resize_event(120, 50),
567        ];
568        sim.inject_events(&events);
569
570        assert_eq!(sim.model().history, vec![(80, 24), (100, 40), (120, 50)]);
571        assert_eq!(sim.model().last, Some((120, 50)));
572    }
573
574    #[test]
575    fn resize_events_after_quit_are_ignored() {
576        let mut sim = ProgramSimulator::new(ResizeTracker::default());
577        sim.init();
578
579        let events = vec![resize_event(80, 24), key_event('q'), resize_event(120, 50)];
580        sim.inject_events(&events);
581
582        assert!(!sim.is_running());
583        assert_eq!(sim.model().history, vec![(80, 24)]);
584        assert_eq!(sim.model().last, Some((80, 24)));
585    }
586
587    #[test]
588    fn send_message_directly() {
589        let mut sim = ProgramSimulator::new(Counter {
590            value: 0,
591            initialized: false,
592        });
593        sim.init();
594
595        sim.send(CounterMsg::Increment);
596        sim.send(CounterMsg::Increment);
597        sim.send(CounterMsg::Decrement);
598
599        assert_eq!(sim.model().value, 1);
600    }
601
602    #[test]
603    fn capture_frame_renders_correctly() {
604        let mut sim = ProgramSimulator::new(Counter {
605            value: 42,
606            initialized: false,
607        });
608        sim.init();
609
610        let buf = sim.capture_frame(80, 24);
611
612        // "Count: 42" should be rendered
613        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('C'));
614        assert_eq!(buf.get(1, 0).unwrap().content.as_char(), Some('o'));
615        assert_eq!(buf.get(7, 0).unwrap().content.as_char(), Some('4'));
616        assert_eq!(buf.get(8, 0).unwrap().content.as_char(), Some('2'));
617    }
618
619    #[test]
620    fn multiple_frame_captures() {
621        let mut sim = ProgramSimulator::new(Counter {
622            value: 0,
623            initialized: false,
624        });
625        sim.init();
626
627        sim.capture_frame(80, 24);
628        sim.send(CounterMsg::Increment);
629        sim.capture_frame(80, 24);
630
631        assert_eq!(sim.frame_count(), 2);
632
633        // First frame: "Count: 0"
634        assert_eq!(
635            sim.frames()[0].get(7, 0).unwrap().content.as_char(),
636            Some('0')
637        );
638        // Second frame: "Count: 1"
639        assert_eq!(
640            sim.frames()[1].get(7, 0).unwrap().content.as_char(),
641            Some('1')
642        );
643    }
644
645    #[test]
646    fn quit_command_stops_running() {
647        let mut sim = ProgramSimulator::new(Counter {
648            value: 0,
649            initialized: false,
650        });
651        sim.init();
652
653        assert!(sim.is_running());
654        sim.send(CounterMsg::Quit);
655        assert!(!sim.is_running());
656    }
657
658    #[test]
659    fn log_command_records_text() {
660        let mut sim = ProgramSimulator::new(Counter {
661            value: 5,
662            initialized: false,
663        });
664        sim.init();
665
666        sim.send(CounterMsg::LogValue);
667
668        assert_eq!(sim.logs(), &["value=5"]);
669    }
670
671    #[test]
672    fn batch_command_executes_all() {
673        let mut sim = ProgramSimulator::new(Counter {
674            value: 0,
675            initialized: false,
676        });
677        sim.init();
678
679        sim.send(CounterMsg::BatchIncrement(5));
680
681        assert_eq!(sim.model().value, 5);
682    }
683
684    #[test]
685    fn tick_command_sets_rate() {
686        let mut sim = ProgramSimulator::new(Counter {
687            value: 0,
688            initialized: false,
689        });
690
691        assert!(sim.tick_rate().is_none());
692
693        // Manually execute a tick command through the model
694        // We'll test by checking the internal tick_rate after setting it
695        // via the execute_cmd path. Since Counter doesn't emit ticks,
696        // we'll test via the command log.
697        sim.execute_cmd(Cmd::tick(Duration::from_millis(100)));
698
699        assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
700    }
701
702    #[test]
703    fn command_log_records_all() {
704        let mut sim = ProgramSimulator::new(Counter {
705            value: 0,
706            initialized: false,
707        });
708        sim.init();
709
710        sim.send(CounterMsg::Increment);
711        sim.send(CounterMsg::Quit);
712
713        // init returns Cmd::None, then Increment returns Cmd::None, then Quit returns Cmd::Quit
714        assert!(sim.command_log().len() >= 3);
715        assert!(matches!(sim.command_log().last(), Some(CmdRecord::Quit)));
716    }
717
718    #[test]
719    fn clear_frames() {
720        let mut sim = ProgramSimulator::new(Counter {
721            value: 0,
722            initialized: false,
723        });
724        sim.capture_frame(10, 10);
725        sim.capture_frame(10, 10);
726        assert_eq!(sim.frame_count(), 2);
727
728        sim.clear_frames();
729        assert_eq!(sim.frame_count(), 0);
730    }
731
732    #[test]
733    fn clear_logs() {
734        let mut sim = ProgramSimulator::new(Counter {
735            value: 0,
736            initialized: false,
737        });
738        sim.init();
739        sim.send(CounterMsg::LogValue);
740        assert_eq!(sim.logs().len(), 1);
741
742        sim.clear_logs();
743        assert!(sim.logs().is_empty());
744    }
745
746    #[test]
747    fn model_mut_access() {
748        let mut sim = ProgramSimulator::new(Counter {
749            value: 0,
750            initialized: false,
751        });
752
753        sim.model_mut().value = 100;
754        assert_eq!(sim.model().value, 100);
755    }
756
757    #[test]
758    fn last_frame() {
759        let mut sim = ProgramSimulator::new(Counter {
760            value: 0,
761            initialized: false,
762        });
763
764        assert!(sim.last_frame().is_none());
765
766        sim.capture_frame(10, 10);
767        assert!(sim.last_frame().is_some());
768    }
769
770    #[test]
771    fn send_after_quit_is_ignored() {
772        let mut sim = ProgramSimulator::new(Counter {
773            value: 0,
774            initialized: false,
775        });
776        sim.init();
777
778        sim.send(CounterMsg::Quit);
779        assert!(!sim.is_running());
780
781        sim.send(CounterMsg::Increment);
782        // Value should not change since we quit
783        assert_eq!(sim.model().value, 0);
784    }
785
786    // =========================================================================
787    // DETERMINISM TESTS - ProgramSimulator determinism (bd-2nu8.10.3)
788    // =========================================================================
789
790    #[test]
791    fn identical_inputs_yield_identical_outputs() {
792        fn run_scenario() -> (i32, Vec<u8>) {
793            let mut sim = ProgramSimulator::new(Counter {
794                value: 0,
795                initialized: false,
796            });
797            sim.init();
798
799            sim.send(CounterMsg::Increment);
800            sim.send(CounterMsg::Increment);
801            sim.send(CounterMsg::Decrement);
802            sim.send(CounterMsg::BatchIncrement(3));
803
804            let buf = sim.capture_frame(20, 10);
805            let mut frame_bytes = Vec::new();
806            for y in 0..10 {
807                for x in 0..20 {
808                    if let Some(cell) = buf.get(x, y)
809                        && let Some(c) = cell.content.as_char()
810                    {
811                        frame_bytes.push(c as u8);
812                    }
813                }
814            }
815            (sim.model().value, frame_bytes)
816        }
817
818        let (value1, frame1) = run_scenario();
819        let (value2, frame2) = run_scenario();
820        let (value3, frame3) = run_scenario();
821
822        assert_eq!(value1, value2);
823        assert_eq!(value2, value3);
824        assert_eq!(value1, 4); // 0 + 1 + 1 - 1 + 3 = 4
825
826        assert_eq!(frame1, frame2);
827        assert_eq!(frame2, frame3);
828    }
829
830    #[test]
831    fn command_log_records_in_order() {
832        let mut sim = ProgramSimulator::new(Counter {
833            value: 0,
834            initialized: false,
835        });
836        sim.init();
837
838        sim.send(CounterMsg::Increment);
839        sim.send(CounterMsg::LogValue);
840        sim.send(CounterMsg::Increment);
841        sim.send(CounterMsg::LogValue);
842
843        let log = sim.command_log();
844
845        // Find Log entries and verify they're in order
846        let log_entries: Vec<_> = log
847            .iter()
848            .filter_map(|r| {
849                if let CmdRecord::Log(s) = r {
850                    Some(s.as_str())
851                } else {
852                    None
853                }
854            })
855            .collect();
856
857        assert_eq!(log_entries, vec!["value=1", "value=2"]);
858    }
859
860    #[test]
861    fn sequence_command_records_correctly() {
862        // Model that emits a sequence command
863        struct SeqModel {
864            steps: Vec<i32>,
865        }
866
867        #[derive(Debug)]
868        enum SeqMsg {
869            Step(i32),
870            TriggerSeq,
871        }
872
873        impl From<Event> for SeqMsg {
874            fn from(_: Event) -> Self {
875                SeqMsg::Step(0)
876            }
877        }
878
879        impl Model for SeqModel {
880            type Message = SeqMsg;
881
882            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
883                match msg {
884                    SeqMsg::Step(n) => {
885                        self.steps.push(n);
886                        Cmd::none()
887                    }
888                    SeqMsg::TriggerSeq => Cmd::sequence(vec![
889                        Cmd::msg(SeqMsg::Step(1)),
890                        Cmd::msg(SeqMsg::Step(2)),
891                        Cmd::msg(SeqMsg::Step(3)),
892                    ]),
893                }
894            }
895
896            fn view(&self, _frame: &mut Frame) {}
897        }
898
899        let mut sim = ProgramSimulator::new(SeqModel { steps: vec![] });
900        sim.init();
901        sim.send(SeqMsg::TriggerSeq);
902
903        // Verify sequence is recorded
904        let has_sequence = sim
905            .command_log()
906            .iter()
907            .any(|r| matches!(r, CmdRecord::Sequence(3)));
908        assert!(has_sequence, "Should record Sequence(3)");
909
910        // Verify steps executed in order
911        assert_eq!(sim.model().steps, vec![1, 2, 3]);
912    }
913
914    #[test]
915    fn batch_command_records_correctly() {
916        let mut sim = ProgramSimulator::new(Counter {
917            value: 0,
918            initialized: false,
919        });
920        sim.init();
921
922        sim.send(CounterMsg::BatchIncrement(5));
923
924        // Should have Batch(5) in the log
925        let has_batch = sim
926            .command_log()
927            .iter()
928            .any(|r| matches!(r, CmdRecord::Batch(5)));
929        assert!(has_batch, "Should record Batch(5)");
930
931        assert_eq!(sim.model().value, 5);
932    }
933
934    struct OrderingModel {
935        trace: RefCell<Vec<&'static str>>,
936    }
937
938    impl OrderingModel {
939        fn new() -> Self {
940            Self {
941                trace: RefCell::new(Vec::new()),
942            }
943        }
944
945        fn trace(&self) -> Vec<&'static str> {
946            self.trace.borrow().clone()
947        }
948    }
949
950    #[derive(Debug)]
951    enum OrderingMsg {
952        Step(&'static str),
953        StartSequence,
954        StartBatch,
955    }
956
957    impl From<Event> for OrderingMsg {
958        fn from(_: Event) -> Self {
959            OrderingMsg::StartSequence
960        }
961    }
962
963    impl Model for OrderingModel {
964        type Message = OrderingMsg;
965
966        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
967            match msg {
968                OrderingMsg::Step(tag) => {
969                    self.trace.borrow_mut().push(tag);
970                    Cmd::none()
971                }
972                OrderingMsg::StartSequence => Cmd::sequence(vec![
973                    Cmd::msg(OrderingMsg::Step("seq-1")),
974                    Cmd::msg(OrderingMsg::Step("seq-2")),
975                    Cmd::msg(OrderingMsg::Step("seq-3")),
976                ]),
977                OrderingMsg::StartBatch => Cmd::batch(vec![
978                    Cmd::msg(OrderingMsg::Step("batch-1")),
979                    Cmd::msg(OrderingMsg::Step("batch-2")),
980                    Cmd::msg(OrderingMsg::Step("batch-3")),
981                ]),
982            }
983        }
984
985        fn view(&self, _frame: &mut Frame) {
986            self.trace.borrow_mut().push("view");
987        }
988    }
989
990    #[test]
991    fn sequence_preserves_update_order_before_view() {
992        let mut sim = ProgramSimulator::new(OrderingModel::new());
993        sim.init();
994
995        sim.send(OrderingMsg::StartSequence);
996        sim.capture_frame(1, 1);
997
998        assert_eq!(sim.model().trace(), vec!["seq-1", "seq-2", "seq-3", "view"]);
999    }
1000
1001    #[test]
1002    fn batch_preserves_update_order_before_view() {
1003        let mut sim = ProgramSimulator::new(OrderingModel::new());
1004        sim.init();
1005
1006        sim.send(OrderingMsg::StartBatch);
1007        sim.capture_frame(1, 1);
1008
1009        assert_eq!(
1010            sim.model().trace(),
1011            vec!["batch-1", "batch-2", "batch-3", "view"]
1012        );
1013    }
1014
1015    #[test]
1016    fn frame_dimensions_match_request() {
1017        let mut sim = ProgramSimulator::new(Counter {
1018            value: 42,
1019            initialized: false,
1020        });
1021        sim.init();
1022
1023        let buf = sim.capture_frame(100, 50);
1024        assert_eq!(buf.width(), 100);
1025        assert_eq!(buf.height(), 50);
1026    }
1027
1028    #[test]
1029    fn multiple_frame_captures_are_independent() {
1030        let mut sim = ProgramSimulator::new(Counter {
1031            value: 0,
1032            initialized: false,
1033        });
1034        sim.init();
1035
1036        // Capture at value 0
1037        sim.capture_frame(20, 10);
1038
1039        // Change value
1040        sim.send(CounterMsg::Increment);
1041        sim.send(CounterMsg::Increment);
1042
1043        // Capture at value 2
1044        sim.capture_frame(20, 10);
1045
1046        let frames = sim.frames();
1047        assert_eq!(frames.len(), 2);
1048
1049        // First frame should show "Count: 0"
1050        assert_eq!(frames[0].get(7, 0).unwrap().content.as_char(), Some('0'));
1051
1052        // Second frame should show "Count: 2"
1053        assert_eq!(frames[1].get(7, 0).unwrap().content.as_char(), Some('2'));
1054    }
1055
1056    #[test]
1057    fn inject_events_processes_in_order() {
1058        let mut sim = ProgramSimulator::new(Counter {
1059            value: 0,
1060            initialized: false,
1061        });
1062        sim.init();
1063
1064        // '+' increments, '-' decrements
1065        let events = vec![
1066            key_event('+'),
1067            key_event('+'),
1068            key_event('+'),
1069            key_event('-'),
1070            key_event('+'),
1071        ];
1072
1073        sim.inject_events(&events);
1074
1075        // 0 + 1 + 1 + 1 - 1 + 1 = 3
1076        assert_eq!(sim.model().value, 3);
1077    }
1078
1079    #[test]
1080    fn task_command_records_task() {
1081        struct TaskModel {
1082            result: Option<i32>,
1083        }
1084
1085        #[derive(Debug)]
1086        enum TaskMsg {
1087            SetResult(i32),
1088            SpawnTask,
1089        }
1090
1091        impl From<Event> for TaskMsg {
1092            fn from(_: Event) -> Self {
1093                TaskMsg::SetResult(0)
1094            }
1095        }
1096
1097        impl Model for TaskModel {
1098            type Message = TaskMsg;
1099
1100            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
1101                match msg {
1102                    TaskMsg::SetResult(v) => {
1103                        self.result = Some(v);
1104                        Cmd::none()
1105                    }
1106                    TaskMsg::SpawnTask => Cmd::task(|| {
1107                        // Simulate computation
1108                        TaskMsg::SetResult(42)
1109                    }),
1110                }
1111            }
1112
1113            fn view(&self, _frame: &mut Frame) {}
1114        }
1115
1116        let mut sim = ProgramSimulator::new(TaskModel { result: None });
1117        sim.init();
1118        sim.send(TaskMsg::SpawnTask);
1119
1120        // Task should execute synchronously in simulator
1121        assert_eq!(sim.model().result, Some(42));
1122
1123        // Should have Task record in command log
1124        let has_task = sim
1125            .command_log()
1126            .iter()
1127            .any(|r| matches!(r, CmdRecord::Task));
1128        assert!(has_task);
1129    }
1130
1131    #[test]
1132    fn tick_rate_is_set() {
1133        let mut sim = ProgramSimulator::new(Counter {
1134            value: 0,
1135            initialized: false,
1136        });
1137
1138        assert!(sim.tick_rate().is_none());
1139
1140        sim.execute_cmd(Cmd::tick(std::time::Duration::from_millis(100)));
1141
1142        assert_eq!(sim.tick_rate(), Some(std::time::Duration::from_millis(100)));
1143    }
1144
1145    #[test]
1146    fn logs_accumulate_across_messages() {
1147        let mut sim = ProgramSimulator::new(Counter {
1148            value: 0,
1149            initialized: false,
1150        });
1151        sim.init();
1152
1153        sim.send(CounterMsg::LogValue);
1154        sim.send(CounterMsg::Increment);
1155        sim.send(CounterMsg::LogValue);
1156        sim.send(CounterMsg::Increment);
1157        sim.send(CounterMsg::LogValue);
1158
1159        assert_eq!(sim.logs().len(), 3);
1160        assert_eq!(sim.logs()[0], "value=0");
1161        assert_eq!(sim.logs()[1], "value=1");
1162        assert_eq!(sim.logs()[2], "value=2");
1163    }
1164
1165    #[test]
1166    fn deterministic_frame_content_across_runs() {
1167        fn capture_frame_content(value: i32) -> Vec<Option<char>> {
1168            let mut sim = ProgramSimulator::new(Counter {
1169                value,
1170                initialized: false,
1171            });
1172            sim.init();
1173
1174            let buf = sim.capture_frame(15, 1);
1175            (0..15)
1176                .map(|x| buf.get(x, 0).and_then(|c| c.content.as_char()))
1177                .collect()
1178        }
1179
1180        let content1 = capture_frame_content(123);
1181        let content2 = capture_frame_content(123);
1182        let content3 = capture_frame_content(123);
1183
1184        assert_eq!(content1, content2);
1185        assert_eq!(content2, content3);
1186
1187        // Should be "Count: 123" followed by None (unwritten cells)
1188        let expected: Vec<Option<char>> = "Count: 123"
1189            .chars()
1190            .map(Some)
1191            .chain(std::iter::repeat_n(None, 5))
1192            .collect();
1193        assert_eq!(content1, expected);
1194    }
1195
1196    #[test]
1197    fn complex_scenario_is_deterministic() {
1198        fn run_complex_scenario() -> (i32, usize, Vec<String>) {
1199            let mut sim = ProgramSimulator::new(Counter {
1200                value: 0,
1201                initialized: false,
1202            });
1203            sim.init();
1204
1205            // Complex sequence of operations
1206            for _ in 0..10 {
1207                sim.send(CounterMsg::Increment);
1208            }
1209            sim.send(CounterMsg::LogValue);
1210
1211            sim.send(CounterMsg::BatchIncrement(5));
1212            sim.send(CounterMsg::LogValue);
1213
1214            for _ in 0..3 {
1215                sim.send(CounterMsg::Decrement);
1216            }
1217            sim.send(CounterMsg::LogValue);
1218
1219            sim.send(CounterMsg::Reset);
1220            sim.send(CounterMsg::LogValue);
1221
1222            sim.capture_frame(20, 10);
1223
1224            (
1225                sim.model().value,
1226                sim.command_log().len(),
1227                sim.logs().to_vec(),
1228            )
1229        }
1230
1231        let result1 = run_complex_scenario();
1232        let result2 = run_complex_scenario();
1233
1234        assert_eq!(result1.0, result2.0);
1235        assert_eq!(result1.1, result2.1);
1236        assert_eq!(result1.2, result2.2);
1237    }
1238
1239    #[test]
1240    fn model_unchanged_when_not_running() {
1241        let mut sim = ProgramSimulator::new(Counter {
1242            value: 5,
1243            initialized: false,
1244        });
1245        sim.init();
1246
1247        sim.send(CounterMsg::Quit);
1248
1249        let value_before = sim.model().value;
1250        sim.send(CounterMsg::Increment);
1251        sim.send(CounterMsg::BatchIncrement(10));
1252        let value_after = sim.model().value;
1253
1254        assert_eq!(value_before, value_after);
1255    }
1256
1257    #[test]
1258    fn init_produces_consistent_command_log() {
1259        // Model with init that returns a command
1260        struct InitModel {
1261            init_ran: bool,
1262        }
1263
1264        #[derive(Debug)]
1265        enum InitMsg {
1266            MarkInit,
1267        }
1268
1269        impl From<Event> for InitMsg {
1270            fn from(_: Event) -> Self {
1271                InitMsg::MarkInit
1272            }
1273        }
1274
1275        impl Model for InitModel {
1276            type Message = InitMsg;
1277
1278            fn init(&mut self) -> Cmd<Self::Message> {
1279                Cmd::msg(InitMsg::MarkInit)
1280            }
1281
1282            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
1283                match msg {
1284                    InitMsg::MarkInit => {
1285                        self.init_ran = true;
1286                        Cmd::none()
1287                    }
1288                }
1289            }
1290
1291            fn view(&self, _frame: &mut Frame) {}
1292        }
1293
1294        let mut sim1 = ProgramSimulator::new(InitModel { init_ran: false });
1295        let mut sim2 = ProgramSimulator::new(InitModel { init_ran: false });
1296
1297        sim1.init();
1298        sim2.init();
1299
1300        assert_eq!(sim1.model().init_ran, sim2.model().init_ran);
1301        assert_eq!(sim1.command_log().len(), sim2.command_log().len());
1302    }
1303
1304    #[test]
1305    fn execute_cmd_directly() {
1306        let mut sim = ProgramSimulator::new(Counter {
1307            value: 0,
1308            initialized: false,
1309        });
1310
1311        // Execute commands directly without going through update
1312        sim.execute_cmd(Cmd::log("direct log"));
1313        sim.execute_cmd(Cmd::tick(std::time::Duration::from_secs(1)));
1314
1315        assert_eq!(sim.logs(), &["direct log"]);
1316        assert_eq!(sim.tick_rate(), Some(std::time::Duration::from_secs(1)));
1317    }
1318
1319    #[test]
1320    fn save_restore_are_noops_in_simulator() {
1321        let mut sim = ProgramSimulator::new(Counter {
1322            value: 7,
1323            initialized: false,
1324        });
1325        sim.init();
1326
1327        let log_len = sim.command_log().len();
1328        let tick_rate = sim.tick_rate();
1329        let value_before = sim.model().value;
1330
1331        sim.execute_cmd(Cmd::save_state());
1332        sim.execute_cmd(Cmd::restore_state());
1333
1334        assert_eq!(sim.command_log().len(), log_len);
1335        assert_eq!(sim.tick_rate(), tick_rate);
1336        assert_eq!(sim.model().value, value_before);
1337        assert!(sim.is_running());
1338    }
1339
1340    #[test]
1341    fn grapheme_pool_is_reused() {
1342        let mut sim = ProgramSimulator::new(Counter {
1343            value: 0,
1344            initialized: false,
1345        });
1346        sim.init();
1347
1348        // Capture multiple frames - pool should be reused
1349        for i in 0..10 {
1350            sim.model_mut().value = i;
1351            sim.capture_frame(80, 24);
1352        }
1353
1354        assert_eq!(sim.frame_count(), 10);
1355    }
1356
1357    // =========================================================================
1358    // LIFECYCLE CONTRACT TESTS (bd-1dg21)
1359    //
1360    // These tests capture the observable lifecycle contract for subscriptions,
1361    // commands, processes, and teardown ordering. The Asupersync migration
1362    // MUST preserve all behaviors documented here.
1363    // =========================================================================
1364
1365    /// CONTRACT: Model::init() is called exactly once, before any update() calls.
1366    /// init() return value is executed as a command.
1367    #[test]
1368    fn contract_init_called_once_before_updates() {
1369        use std::sync::atomic::{AtomicUsize, Ordering as AO};
1370
1371        struct InitTracker {
1372            init_count: Arc<AtomicUsize>,
1373            update_count: Arc<AtomicUsize>,
1374            init_saw_zero_updates: bool,
1375        }
1376
1377        #[derive(Debug)]
1378        enum TrackerMsg {
1379            FromInit,
1380            Manual,
1381        }
1382
1383        impl From<Event> for TrackerMsg {
1384            fn from(_: Event) -> Self {
1385                TrackerMsg::Manual
1386            }
1387        }
1388
1389        impl Model for InitTracker {
1390            type Message = TrackerMsg;
1391
1392            fn init(&mut self) -> Cmd<Self::Message> {
1393                self.init_count.fetch_add(1, AO::SeqCst);
1394                self.init_saw_zero_updates = self.update_count.load(AO::SeqCst) == 0;
1395                Cmd::msg(TrackerMsg::FromInit)
1396            }
1397
1398            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
1399                self.update_count.fetch_add(1, AO::SeqCst);
1400                Cmd::none()
1401            }
1402
1403            fn view(&self, _frame: &mut Frame) {}
1404        }
1405
1406        let init_count = Arc::new(AtomicUsize::new(0));
1407        let update_count = Arc::new(AtomicUsize::new(0));
1408
1409        let mut sim = ProgramSimulator::new(InitTracker {
1410            init_count: init_count.clone(),
1411            update_count: update_count.clone(),
1412            init_saw_zero_updates: false,
1413        });
1414
1415        sim.init();
1416        assert_eq!(init_count.load(AO::SeqCst), 1, "init called exactly once");
1417        assert!(
1418            sim.model().init_saw_zero_updates,
1419            "init must run before any update"
1420        );
1421        // init returned Cmd::msg(FromInit) which triggered one update
1422        assert_eq!(
1423            update_count.load(AO::SeqCst),
1424            1,
1425            "init's command should trigger update"
1426        );
1427    }
1428
1429    /// CONTRACT: on_shutdown() is called during program shutdown, providing
1430    /// the model a chance to emit final commands.
1431    #[test]
1432    fn contract_on_shutdown_called_with_final_commands() {
1433        struct ShutdownTracker {
1434            shutdown_called: bool,
1435            final_log: Option<String>,
1436        }
1437
1438        #[derive(Debug)]
1439        enum ShutMsg {
1440            Quit,
1441            LogFinal(String),
1442        }
1443
1444        impl From<Event> for ShutMsg {
1445            fn from(_: Event) -> Self {
1446                ShutMsg::Quit
1447            }
1448        }
1449
1450        impl Model for ShutdownTracker {
1451            type Message = ShutMsg;
1452
1453            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
1454                match msg {
1455                    ShutMsg::Quit => Cmd::quit(),
1456                    ShutMsg::LogFinal(s) => {
1457                        self.final_log = Some(s);
1458                        Cmd::none()
1459                    }
1460                }
1461            }
1462
1463            fn view(&self, _frame: &mut Frame) {}
1464
1465            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
1466                self.shutdown_called = true;
1467                Cmd::msg(ShutMsg::LogFinal("shutdown-complete".into()))
1468            }
1469        }
1470
1471        let mut sim = ProgramSimulator::new(ShutdownTracker {
1472            shutdown_called: false,
1473            final_log: None,
1474        });
1475        sim.init();
1476        sim.send(ShutMsg::Quit);
1477
1478        // Manually call on_shutdown to verify the contract
1479        // (ProgramSimulator doesn't auto-call it; the real runtime does)
1480        let shutdown_cmd = sim.model_mut().on_shutdown();
1481        sim.execute_cmd(shutdown_cmd);
1482
1483        assert!(sim.model().shutdown_called, "on_shutdown must be called");
1484        assert_eq!(
1485            sim.model().final_log.as_deref(),
1486            Some("shutdown-complete"),
1487            "on_shutdown commands must be executed"
1488        );
1489    }
1490
1491    /// CONTRACT: Cmd::Batch stops executing remaining commands after Cmd::Quit.
1492    #[test]
1493    fn contract_batch_stops_on_quit() {
1494        struct BatchQuitModel {
1495            steps: Vec<&'static str>,
1496        }
1497
1498        #[derive(Debug)]
1499        enum BQMsg {
1500            Step(&'static str),
1501            TriggerBatchWithQuit,
1502        }
1503
1504        impl From<Event> for BQMsg {
1505            fn from(_: Event) -> Self {
1506                BQMsg::Step("event")
1507            }
1508        }
1509
1510        impl Model for BatchQuitModel {
1511            type Message = BQMsg;
1512
1513            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
1514                match msg {
1515                    BQMsg::Step(s) => {
1516                        self.steps.push(s);
1517                        Cmd::none()
1518                    }
1519                    BQMsg::TriggerBatchWithQuit => Cmd::batch(vec![
1520                        Cmd::msg(BQMsg::Step("before-quit")),
1521                        Cmd::quit(),
1522                        Cmd::msg(BQMsg::Step("after-quit")),
1523                    ]),
1524                }
1525            }
1526
1527            fn view(&self, _frame: &mut Frame) {}
1528        }
1529
1530        let mut sim = ProgramSimulator::new(BatchQuitModel { steps: vec![] });
1531        sim.init();
1532        sim.send(BQMsg::TriggerBatchWithQuit);
1533
1534        assert!(!sim.is_running(), "should be stopped");
1535        assert_eq!(
1536            sim.model().steps,
1537            vec!["before-quit"],
1538            "commands after Quit in a Batch must not execute"
1539        );
1540    }
1541
1542    /// CONTRACT: Cmd::Sequence stops executing remaining commands after Cmd::Quit.
1543    #[test]
1544    fn contract_sequence_stops_on_quit() {
1545        struct SeqQuitModel {
1546            steps: Vec<&'static str>,
1547        }
1548
1549        #[derive(Debug)]
1550        enum SQMsg {
1551            Step(&'static str),
1552            TriggerSeqWithQuit,
1553        }
1554
1555        impl From<Event> for SQMsg {
1556            fn from(_: Event) -> Self {
1557                SQMsg::Step("event")
1558            }
1559        }
1560
1561        impl Model for SeqQuitModel {
1562            type Message = SQMsg;
1563
1564            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
1565                match msg {
1566                    SQMsg::Step(s) => {
1567                        self.steps.push(s);
1568                        Cmd::none()
1569                    }
1570                    SQMsg::TriggerSeqWithQuit => Cmd::sequence(vec![
1571                        Cmd::msg(SQMsg::Step("before-quit")),
1572                        Cmd::quit(),
1573                        Cmd::msg(SQMsg::Step("after-quit")),
1574                    ]),
1575                }
1576            }
1577
1578            fn view(&self, _frame: &mut Frame) {}
1579        }
1580
1581        let mut sim = ProgramSimulator::new(SeqQuitModel { steps: vec![] });
1582        sim.init();
1583        sim.send(SQMsg::TriggerSeqWithQuit);
1584
1585        assert!(!sim.is_running(), "should be stopped");
1586        assert_eq!(
1587            sim.model().steps,
1588            vec!["before-quit"],
1589            "commands after Quit in a Sequence must not execute"
1590        );
1591    }
1592
1593    /// CONTRACT: Cmd::Task results are routed back through Model::update().
1594    /// In simulator mode, tasks execute synchronously.
1595    #[test]
1596    fn contract_task_result_routes_through_update() {
1597        struct TaskModel {
1598            trace: Vec<String>,
1599        }
1600
1601        #[derive(Debug)]
1602        enum TMsg {
1603            Spawn,
1604            TaskDone(i32),
1605        }
1606
1607        impl From<Event> for TMsg {
1608            fn from(_: Event) -> Self {
1609                TMsg::Spawn
1610            }
1611        }
1612
1613        impl Model for TaskModel {
1614            type Message = TMsg;
1615
1616            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
1617                match msg {
1618                    TMsg::Spawn => {
1619                        self.trace.push("spawn".into());
1620                        Cmd::task(|| TMsg::TaskDone(42))
1621                    }
1622                    TMsg::TaskDone(v) => {
1623                        self.trace.push(format!("done:{v}"));
1624                        Cmd::none()
1625                    }
1626                }
1627            }
1628
1629            fn view(&self, _frame: &mut Frame) {}
1630        }
1631
1632        let mut sim = ProgramSimulator::new(TaskModel { trace: vec![] });
1633        sim.init();
1634        sim.send(TMsg::Spawn);
1635
1636        assert_eq!(
1637            sim.model().trace,
1638            vec!["spawn", "done:42"],
1639            "task result must route through update()"
1640        );
1641    }
1642
1643    /// CONTRACT: Cmd::Msg causes recursive dispatch through update().
1644    /// The message is processed immediately, not deferred.
1645    #[test]
1646    fn contract_cmd_msg_dispatches_recursively() {
1647        struct RecursiveModel {
1648            trace: Vec<i32>,
1649        }
1650
1651        #[derive(Debug)]
1652        enum RMsg {
1653            Chain(i32),
1654        }
1655
1656        impl From<Event> for RMsg {
1657            fn from(_: Event) -> Self {
1658                RMsg::Chain(0)
1659            }
1660        }
1661
1662        impl Model for RecursiveModel {
1663            type Message = RMsg;
1664
1665            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
1666                match msg {
1667                    RMsg::Chain(n) => {
1668                        self.trace.push(n);
1669                        if n < 3 {
1670                            Cmd::msg(RMsg::Chain(n + 1))
1671                        } else {
1672                            Cmd::none()
1673                        }
1674                    }
1675                }
1676            }
1677
1678            fn view(&self, _frame: &mut Frame) {}
1679        }
1680
1681        let mut sim = ProgramSimulator::new(RecursiveModel { trace: vec![] });
1682        sim.init();
1683        sim.send(RMsg::Chain(0));
1684
1685        assert_eq!(
1686            sim.model().trace,
1687            vec![0, 1, 2, 3],
1688            "Cmd::Msg must dispatch recursively through update()"
1689        );
1690    }
1691
1692    /// CONTRACT: Cmd::batch with empty vec produces Cmd::None.
1693    /// Cmd::batch with single element unwraps to that element.
1694    #[test]
1695    fn contract_batch_normalization() {
1696        let empty: Cmd<CounterMsg> = Cmd::batch(vec![]);
1697        assert!(matches!(empty, Cmd::None), "empty batch must be Cmd::None");
1698
1699        let single: Cmd<CounterMsg> = Cmd::batch(vec![Cmd::quit()]);
1700        assert!(
1701            matches!(single, Cmd::Quit),
1702            "single-element batch must unwrap"
1703        );
1704
1705        let multi: Cmd<CounterMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
1706        assert!(
1707            matches!(multi, Cmd::Batch(_)),
1708            "multi-element batch stays Batch"
1709        );
1710    }
1711
1712    /// CONTRACT: Cmd::sequence with empty vec produces Cmd::None.
1713    #[test]
1714    fn contract_sequence_normalization() {
1715        let empty: Cmd<CounterMsg> = Cmd::sequence(vec![]);
1716        assert!(
1717            matches!(empty, Cmd::None),
1718            "empty sequence must be Cmd::None"
1719        );
1720    }
1721
1722    /// CONTRACT: After Cmd::Quit, no further messages are processed.
1723    /// This applies to both inject_events and send.
1724    #[test]
1725    fn contract_no_processing_after_quit() {
1726        let mut sim = ProgramSimulator::new(Counter {
1727            value: 0,
1728            initialized: false,
1729        });
1730        sim.init();
1731
1732        sim.send(CounterMsg::Increment); // value = 1
1733        sim.send(CounterMsg::Quit);
1734        sim.send(CounterMsg::Increment); // should be ignored
1735        sim.send(CounterMsg::Increment); // should be ignored
1736
1737        assert_eq!(sim.model().value, 1, "messages after Quit must be ignored");
1738        assert!(!sim.is_running());
1739
1740        // Also via inject_events
1741        sim.inject_events(&[key_event('+'), key_event('+')]);
1742        assert_eq!(
1743            sim.model().value,
1744            1,
1745            "events after Quit must also be ignored"
1746        );
1747    }
1748}