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