Skip to main content

ftui_harness/
lab_integration.rs

1#![forbid(unsafe_code)]
2
3//! FrankenLab integration: deterministic model simulation with LabScenario.
4//!
5//! Bridges [`LabScenario`](crate::determinism::LabScenario) (seed-controlled
6//! scheduling, JSONL logging, tracing spans) with
7//! [`ProgramSimulator`](ftui_runtime::simulator::ProgramSimulator) (headless
8//! model execution, frame capture).
9//!
10//! # Design
11//!
12//! [`Lab`] is the entry point. It creates a [`LabSession`] that wraps a
13//! `ProgramSimulator<M>` with deterministic time, structured logging, and
14//! frame-checksum recording for replay verification.
15//!
16//! # Example
17//!
18//! ```ignore
19//! use ftui_harness::lab_integration::{Lab, LabConfig};
20//!
21//! let config = LabConfig::new("my_test", "theme_switch", 42)
22//!     .viewport(80, 24)
23//!     .time_step_ms(16);
24//!
25//! let run = Lab::run_scenario(config, MyModel::new(), |session| {
26//!     session.init();
27//!     session.send(MyMsg::SwitchTheme);
28//!     session.tick();
29//!     session.capture_frame();
30//!     session.send(MyMsg::SwitchTheme);
31//!     session.tick();
32//!     session.capture_frame();
33//! });
34//!
35//! assert!(run.result.deterministic);
36//! assert_eq!(run.output.frame_count, 2);
37//! // Replay: same seed → identical checksums
38//! ```
39
40use std::sync::atomic::{AtomicU64, Ordering};
41
42use crate::determinism::{JsonValue, LabScenario, LabScenarioRun, TestJsonlLogger};
43use ftui_core::event::Event;
44use ftui_render::buffer::Buffer;
45use ftui_runtime::program::Model;
46use ftui_runtime::simulator::ProgramSimulator;
47use tracing::info_span;
48
49/// Global counter for recordings created.
50static LAB_RECORDINGS_TOTAL: AtomicU64 = AtomicU64::new(0);
51/// Global counter for replays executed.
52static LAB_REPLAYS_TOTAL: AtomicU64 = AtomicU64::new(0);
53
54/// Read the total number of recordings created in-process.
55#[must_use]
56pub fn lab_recordings_total() -> u64 {
57    LAB_RECORDINGS_TOTAL.load(Ordering::Relaxed)
58}
59
60/// Read the total number of replays executed in-process.
61#[must_use]
62pub fn lab_replays_total() -> u64 {
63    LAB_REPLAYS_TOTAL.load(Ordering::Relaxed)
64}
65
66// ============================================================================
67// Configuration
68// ============================================================================
69
70/// Configuration for a FrankenLab scenario run.
71#[derive(Debug, Clone)]
72pub struct LabConfig {
73    /// Prefix for JSONL logger and run IDs.
74    pub prefix: String,
75    /// Scenario name (used in tracing spans and JSONL).
76    pub scenario_name: String,
77    /// Deterministic seed.
78    pub seed: u64,
79    /// Viewport width for frame captures.
80    pub viewport_width: u16,
81    /// Viewport height for frame captures.
82    pub viewport_height: u16,
83    /// Time step in milliseconds for the deterministic clock.
84    pub time_step_ms: u64,
85    /// Whether to log each captured frame's checksum to JSONL.
86    pub log_frame_checksums: bool,
87}
88
89impl LabConfig {
90    /// Create a new configuration with defaults.
91    ///
92    /// Defaults: 80x24 viewport, 16ms time step, frame checksum logging on.
93    pub fn new(prefix: &str, scenario_name: &str, seed: u64) -> Self {
94        Self {
95            prefix: prefix.to_string(),
96            scenario_name: scenario_name.to_string(),
97            seed,
98            viewport_width: 80,
99            viewport_height: 24,
100            time_step_ms: 16,
101            log_frame_checksums: true,
102        }
103    }
104
105    /// Set the viewport dimensions for frame captures.
106    #[must_use]
107    pub fn viewport(mut self, width: u16, height: u16) -> Self {
108        self.viewport_width = width;
109        self.viewport_height = height;
110        self
111    }
112
113    /// Set the deterministic time step in milliseconds.
114    #[must_use]
115    pub fn time_step_ms(mut self, ms: u64) -> Self {
116        self.time_step_ms = ms;
117        self
118    }
119
120    /// Enable or disable JSONL logging of frame checksums.
121    #[must_use]
122    pub fn log_frame_checksums(mut self, enabled: bool) -> Self {
123        self.log_frame_checksums = enabled;
124        self
125    }
126}
127
128// ============================================================================
129// Session (mutable handle passed to scenario closures)
130// ============================================================================
131
132/// A frame checksum record for replay verification.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct FrameRecord {
135    /// Frame index (0-based).
136    pub index: usize,
137    /// Deterministic timestamp when the frame was captured.
138    pub timestamp_ms: u64,
139    /// FNV-1a checksum of the frame buffer cells.
140    pub checksum: u64,
141}
142
143/// Record of an injected event for ordering verification.
144#[derive(Debug, Clone)]
145pub struct EventRecord {
146    /// Deterministic timestamp when the event was injected.
147    pub timestamp_ms: u64,
148    /// Sequential index of this event.
149    pub sequence: u64,
150    /// Human-readable label for the event kind.
151    pub kind: String,
152}
153
154/// Active FrankenLab session wrapping a `ProgramSimulator`.
155///
156/// Provides deterministic time injection, structured event logging, and
157/// frame-checksum recording. All operations are logged to JSONL via the
158/// underlying [`TestJsonlLogger`].
159pub struct LabSession<M: Model> {
160    sim: ProgramSimulator<M>,
161    logger: TestJsonlLogger,
162    viewport_width: u16,
163    viewport_height: u16,
164    log_frame_checksums: bool,
165    frame_records: Vec<FrameRecord>,
166    event_log: Vec<EventRecord>,
167    tick_count: u64,
168    anomaly_count: u64,
169    last_event_ms: u64,
170}
171
172impl<M: Model> LabSession<M> {
173    /// Initialize the model (calls `Model::init()`).
174    ///
175    /// Should be called once before injecting events or capturing frames.
176    pub fn init(&mut self) {
177        self.sim.init();
178        self.logger.log(
179            "lab.session.init",
180            &[(
181                "viewport",
182                JsonValue::raw(format!(
183                    "[{},{}]",
184                    self.viewport_width, self.viewport_height
185                )),
186            )],
187        );
188    }
189
190    /// Send a message directly to the model.
191    pub fn send(&mut self, msg: M::Message) {
192        let now_ms = self.logger.fixture().now_ms();
193        self.check_time_ordering(now_ms, "send");
194        let seq = self.event_log.len() as u64;
195        self.event_log.push(EventRecord {
196            timestamp_ms: now_ms,
197            sequence: seq,
198            kind: "message".to_string(),
199        });
200        self.sim.send(msg);
201    }
202
203    /// Inject a terminal event into the model.
204    pub fn inject_event(&mut self, event: Event) {
205        let now_ms = self.logger.fixture().now_ms();
206        self.check_time_ordering(now_ms, "inject_event");
207        let seq = self.event_log.len() as u64;
208        let kind = event_kind_label(&event);
209        self.event_log.push(EventRecord {
210            timestamp_ms: now_ms,
211            sequence: seq,
212            kind,
213        });
214        self.sim.inject_event(event);
215    }
216
217    /// Inject multiple terminal events in order.
218    pub fn inject_events(&mut self, events: &[Event]) {
219        for event in events {
220            self.inject_event(event.clone());
221        }
222    }
223
224    /// Simulate a tick event (deterministic time advance).
225    ///
226    /// Injects `Event::Tick` into the model. The deterministic clock advances
227    /// by `time_step_ms` for each call to `now_ms()`.
228    pub fn tick(&mut self) {
229        let now_ms = self.logger.fixture().now_ms();
230        self.check_time_ordering(now_ms, "tick");
231        self.tick_count += 1;
232        let seq = self.event_log.len() as u64;
233        self.event_log.push(EventRecord {
234            timestamp_ms: now_ms,
235            sequence: seq,
236            kind: "tick".to_string(),
237        });
238        self.sim.inject_event(Event::Tick);
239    }
240
241    /// Capture the current frame at the configured viewport dimensions.
242    ///
243    /// Records a checksum for replay verification and optionally logs it
244    /// to JSONL.
245    pub fn capture_frame(&mut self) -> &Buffer {
246        let w = self.viewport_width;
247        let h = self.viewport_height;
248        self.capture_frame_inner(w, h)
249    }
250
251    /// Capture a frame at custom dimensions (overriding the configured viewport).
252    pub fn capture_frame_at(&mut self, width: u16, height: u16) -> &Buffer {
253        self.capture_frame_inner(width, height)
254    }
255
256    fn capture_frame_inner(&mut self, width: u16, height: u16) -> &Buffer {
257        let now_ms = self.logger.fixture().now_ms();
258        let buf = self.sim.capture_frame(width, height);
259        let checksum = fnv1a_buffer(buf);
260        let index = self.frame_records.len();
261
262        self.frame_records.push(FrameRecord {
263            index,
264            timestamp_ms: now_ms,
265            checksum,
266        });
267
268        if self.log_frame_checksums {
269            self.logger.log(
270                "lab.frame",
271                &[
272                    ("frame_idx", JsonValue::u64(index as u64)),
273                    ("checksum", JsonValue::str(format!("{checksum:016x}"))),
274                    ("timestamp_ms", JsonValue::u64(now_ms)),
275                    ("width", JsonValue::u64(width as u64)),
276                    ("height", JsonValue::u64(height as u64)),
277                ],
278            );
279        }
280
281        self.sim.last_frame().expect("frame just captured")
282    }
283
284    /// Access the underlying model.
285    pub fn model(&self) -> &M {
286        self.sim.model()
287    }
288
289    /// Access the underlying model mutably.
290    pub fn model_mut(&mut self) -> &mut M {
291        self.sim.model_mut()
292    }
293
294    /// Check if the simulated program is still running.
295    pub fn is_running(&self) -> bool {
296        self.sim.is_running()
297    }
298
299    /// Get all frame checksum records.
300    pub fn frame_records(&self) -> &[FrameRecord] {
301        &self.frame_records
302    }
303
304    /// Get all event records (for ordering verification).
305    pub fn event_log(&self) -> &[EventRecord] {
306        &self.event_log
307    }
308
309    /// Number of ticks injected.
310    pub fn tick_count(&self) -> u64 {
311        self.tick_count
312    }
313
314    /// Number of scheduling anomalies detected.
315    pub fn anomaly_count(&self) -> u64 {
316        self.anomaly_count
317    }
318
319    /// All captured frame buffers.
320    pub fn frames(&self) -> &[Buffer] {
321        self.sim.frames()
322    }
323
324    /// Most recently captured frame.
325    pub fn last_frame(&self) -> Option<&Buffer> {
326        self.sim.last_frame()
327    }
328
329    /// Logs emitted via `Cmd::Log`.
330    pub fn logs(&self) -> &[String] {
331        self.sim.logs()
332    }
333
334    /// Underlying simulator command log.
335    pub fn command_log(&self) -> &[ftui_runtime::simulator::CmdRecord] {
336        self.sim.command_log()
337    }
338
339    /// Access the grapheme pool for text extraction.
340    pub fn pool(&self) -> &ftui_render::grapheme_pool::GraphemePool {
341        self.sim.pool()
342    }
343
344    /// Deterministic monotonic time from the fixture.
345    pub fn now_ms(&self) -> u64 {
346        self.logger.fixture().now_ms()
347    }
348
349    /// Log a custom info event via the JSONL logger.
350    pub fn log_info(&self, event: &str, fields: &[(&str, JsonValue)]) {
351        self.logger.log(event, fields);
352    }
353
354    /// Log a warning (scheduling anomaly or custom) event.
355    pub fn log_warn(&self, anomaly: &str, detail: &str) {
356        self.logger.log(
357            "lab.session.warn",
358            &[
359                ("anomaly", JsonValue::str(anomaly)),
360                ("detail", JsonValue::str(detail)),
361            ],
362        );
363    }
364
365    // ── Internal ─────────────────────────────────────────────────────
366
367    fn check_time_ordering(&mut self, now_ms: u64, operation: &str) {
368        if now_ms < self.last_event_ms {
369            self.anomaly_count += 1;
370            self.logger.log(
371                "lab.session.warn",
372                &[
373                    ("anomaly", JsonValue::str("time_ordering")),
374                    (
375                        "detail",
376                        JsonValue::str(format!(
377                            "{operation}: time went backwards ({now_ms} < {})",
378                            self.last_event_ms
379                        )),
380                    ),
381                ],
382            );
383        }
384        self.last_event_ms = now_ms;
385    }
386
387    fn into_output(self) -> LabOutput {
388        LabOutput {
389            frame_count: self.frame_records.len(),
390            frame_records: self.frame_records,
391            event_count: self.event_log.len(),
392            event_log: self.event_log,
393            tick_count: self.tick_count,
394            anomaly_count: self.anomaly_count,
395        }
396    }
397}
398
399// ============================================================================
400// Output
401// ============================================================================
402
403/// Summary output from a FrankenLab scenario run.
404#[derive(Debug, Clone)]
405pub struct LabOutput {
406    /// Number of frames captured.
407    pub frame_count: usize,
408    /// Frame checksum records for replay verification.
409    pub frame_records: Vec<FrameRecord>,
410    /// Number of events injected.
411    pub event_count: usize,
412    /// Event log for ordering verification.
413    pub event_log: Vec<EventRecord>,
414    /// Number of ticks injected.
415    pub tick_count: u64,
416    /// Number of scheduling anomalies detected.
417    pub anomaly_count: u64,
418}
419
420// ============================================================================
421// Lab entry point
422// ============================================================================
423
424/// FrankenLab — deterministic model testing harness.
425///
426/// Combines [`LabScenario`] (seed, logging, tracing spans, metrics) with
427/// [`ProgramSimulator`] (headless model execution) into a single API.
428pub struct Lab;
429
430impl Lab {
431    /// Run a deterministic scenario with a model.
432    ///
433    /// Creates a [`LabScenario`] for the outer tracing span (including
434    /// `lab.scenario` with `scenario_name`, `seed`, `event_count`,
435    /// `duration_us` fields) and `lab_scenarios_run_total` metric counter.
436    ///
437    /// The closure receives a [`LabSession`] that wraps a `ProgramSimulator`
438    /// with deterministic time injection, JSONL logging, and frame checksums.
439    pub fn run_scenario<M: Model>(
440        config: LabConfig,
441        model: M,
442        run: impl FnOnce(&mut LabSession<M>),
443    ) -> LabScenarioRun<LabOutput> {
444        let scenario = LabScenario::new_with(
445            &config.prefix,
446            &config.scenario_name,
447            config.seed,
448            true, // always deterministic
449            config.time_step_ms,
450        );
451
452        // LabScenario::run() handles the outer span + start/end JSONL +
453        // lab_scenarios_run_total counter. Inside, we create a session-level
454        // logger with the same determinism settings for frame/event logging.
455        scenario.run(|_ctx| {
456            let mut session_logger = TestJsonlLogger::new_with(
457                &format!("{}_session", config.prefix),
458                config.seed,
459                true,
460                config.time_step_ms,
461            );
462            session_logger.add_context_str("scenario_name", &config.scenario_name);
463
464            let mut session = LabSession {
465                sim: ProgramSimulator::new(model),
466                logger: session_logger,
467                viewport_width: config.viewport_width,
468                viewport_height: config.viewport_height,
469                log_frame_checksums: config.log_frame_checksums,
470                frame_records: Vec::new(),
471                event_log: Vec::new(),
472                tick_count: 0,
473                anomaly_count: 0,
474                last_event_ms: 0,
475            };
476
477            run(&mut session);
478            session.into_output()
479        })
480    }
481
482    /// Verify determinism with a custom scenario closure.
483    ///
484    /// Runs `scenario_fn` twice with the same seed and model, asserting
485    /// frame checksum equality.
486    ///
487    /// # Panics
488    ///
489    /// Panics if frame counts differ or any checksum mismatches.
490    pub fn assert_deterministic_with<M, MF, SF>(
491        config: LabConfig,
492        model_factory: MF,
493        scenario_fn: SF,
494    ) -> LabOutput
495    where
496        M: Model,
497        MF: Fn() -> M,
498        SF: Fn(&mut LabSession<M>),
499    {
500        let run1 = Self::run_scenario(config.clone(), model_factory(), |s| scenario_fn(s));
501        let run2 = Self::run_scenario(config, model_factory(), |s| scenario_fn(s));
502
503        assert_eq!(
504            run1.output.frame_count, run2.output.frame_count,
505            "frame count mismatch between identical-seed runs"
506        );
507
508        for (i, (a, b)) in run1
509            .output
510            .frame_records
511            .iter()
512            .zip(run2.output.frame_records.iter())
513            .enumerate()
514        {
515            assert_eq!(
516                a.checksum, b.checksum,
517                "frame {i} checksum mismatch: run1={:016x}, run2={:016x}",
518                a.checksum, b.checksum
519            );
520        }
521
522        run1.output
523    }
524}
525
526// ============================================================================
527// Recording / Replay
528// ============================================================================
529
530/// A captured recording of a deterministic scenario run.
531///
532/// Contains the configuration, frame checksums, and event log from a single
533/// run. Can be replayed with [`Lab::replay`] to verify determinism.
534#[derive(Debug, Clone)]
535pub struct Recording {
536    /// Configuration used for this recording.
537    pub config: LabConfig,
538    /// Scenario metadata from the recording run.
539    pub scenario_name: String,
540    /// Seed used for recording.
541    pub seed: u64,
542    /// Frame checksum records captured during the recording.
543    pub frame_records: Vec<FrameRecord>,
544    /// Event log captured during the recording.
545    pub event_log: Vec<EventRecord>,
546    /// Number of ticks in the recorded scenario.
547    pub tick_count: u64,
548    /// Run identifier for the recording.
549    pub run_id: String,
550}
551
552/// Result of replaying a recording against a new model instance.
553#[derive(Debug, Clone)]
554pub struct ReplayResult {
555    /// Whether the replay matched the recording (no divergence).
556    pub matched: bool,
557    /// Frame checksum records from the replay run.
558    pub replay_frame_records: Vec<FrameRecord>,
559    /// Index of the first divergent frame, if any.
560    pub first_divergence: Option<usize>,
561    /// Descriptive summary of any divergence found.
562    pub divergence_detail: Option<String>,
563    /// Number of frames compared.
564    pub frames_compared: usize,
565}
566
567impl Lab {
568    /// Record a deterministic scenario run.
569    ///
570    /// Executes the scenario closure and captures frame checksums and event
571    /// ordering into a [`Recording`] that can later be replayed with
572    /// [`Lab::replay`].
573    ///
574    /// Emits a `lab.record` tracing span.
575    pub fn record<M: Model>(
576        config: LabConfig,
577        model: M,
578        run: impl FnOnce(&mut LabSession<M>),
579    ) -> Recording {
580        let _span = info_span!(
581            "lab.record",
582            scenario_name = config.scenario_name.as_str(),
583            seed = config.seed,
584        )
585        .entered();
586
587        let scenario_run = Self::run_scenario(config.clone(), model, |session| {
588            session.log_info(
589                "lab.record.start",
590                &[
591                    ("scenario_name", JsonValue::str(&config.scenario_name)),
592                    ("seed", JsonValue::u64(config.seed)),
593                ],
594            );
595            run(session);
596            session.log_info(
597                "lab.record.stop",
598                &[
599                    (
600                        "frame_count",
601                        JsonValue::u64(session.frame_records().len() as u64),
602                    ),
603                    (
604                        "event_count",
605                        JsonValue::u64(session.event_log().len() as u64),
606                    ),
607                ],
608            );
609        });
610
611        LAB_RECORDINGS_TOTAL.fetch_add(1, Ordering::Relaxed);
612
613        Recording {
614            scenario_name: scenario_run.result.scenario_name.clone(),
615            seed: scenario_run.result.seed,
616            run_id: scenario_run.result.run_id.clone(),
617            frame_records: scenario_run.output.frame_records,
618            event_log: scenario_run.output.event_log,
619            tick_count: scenario_run.output.tick_count,
620            config,
621        }
622    }
623
624    /// Replay a recording with a new model instance.
625    ///
626    /// Re-runs the same scenario closure with the same seed/config from the
627    /// recording and compares frame checksums. Returns a [`ReplayResult`]
628    /// indicating whether the replay matched.
629    ///
630    /// Emits a `lab.replay` tracing span. Logs WARN for any divergence.
631    pub fn replay<M: Model>(
632        recording: &Recording,
633        model: M,
634        run: impl FnOnce(&mut LabSession<M>),
635    ) -> ReplayResult {
636        let _span = info_span!(
637            "lab.replay",
638            scenario_name = recording.scenario_name.as_str(),
639            seed = recording.seed,
640            recording_run_id = recording.run_id.as_str(),
641        )
642        .entered();
643
644        let replay_run = Self::run_scenario(recording.config.clone(), model, |session| {
645            session.log_info(
646                "lab.replay.start",
647                &[
648                    ("scenario_name", JsonValue::str(&recording.scenario_name)),
649                    ("seed", JsonValue::u64(recording.seed)),
650                    ("recording_run_id", JsonValue::str(&recording.run_id)),
651                    (
652                        "expected_frames",
653                        JsonValue::u64(recording.frame_records.len() as u64),
654                    ),
655                ],
656            );
657            run(session);
658        });
659
660        LAB_REPLAYS_TOTAL.fetch_add(1, Ordering::Relaxed);
661
662        let replay_frames = &replay_run.output.frame_records;
663        let recorded_frames = &recording.frame_records;
664
665        let frames_compared = recorded_frames.len().min(replay_frames.len());
666        let mut first_divergence = None;
667        let mut divergence_detail = None;
668
669        // Check frame count match
670        if recorded_frames.len() != replay_frames.len() {
671            first_divergence = Some(frames_compared);
672            divergence_detail = Some(format!(
673                "frame count mismatch: recorded={}, replayed={}",
674                recorded_frames.len(),
675                replay_frames.len()
676            ));
677        }
678
679        // Check individual frame checksums
680        for i in 0..frames_compared {
681            if recorded_frames[i].checksum != replay_frames[i].checksum {
682                if first_divergence.is_none() {
683                    first_divergence = Some(i);
684                    divergence_detail = Some(format!(
685                        "frame {i} checksum mismatch: recorded={:016x}, replayed={:016x}",
686                        recorded_frames[i].checksum, replay_frames[i].checksum
687                    ));
688                }
689                break;
690            }
691        }
692
693        let matched = first_divergence.is_none();
694
695        ReplayResult {
696            matched,
697            replay_frame_records: replay_run.output.frame_records,
698            first_divergence,
699            divergence_detail,
700            frames_compared,
701        }
702    }
703
704    /// Record and immediately replay, asserting determinism.
705    ///
706    /// Convenience method: runs the scenario twice (once to record, once to
707    /// replay) and panics if any frame checksum diverges.
708    ///
709    /// # Panics
710    ///
711    /// Panics on any divergence between recording and replay.
712    pub fn assert_replay_deterministic<M, MF, SF>(
713        config: LabConfig,
714        model_factory: MF,
715        scenario_fn: SF,
716    ) -> Recording
717    where
718        M: Model,
719        MF: Fn() -> M,
720        SF: Fn(&mut LabSession<M>),
721    {
722        let recording = Self::record(config, model_factory(), |s| scenario_fn(s));
723        let result = Self::replay(&recording, model_factory(), |s| scenario_fn(s));
724
725        if !result.matched {
726            let detail = result
727                .divergence_detail
728                .unwrap_or_else(|| "unknown divergence".to_string());
729            panic!(
730                "replay diverged from recording (seed={}, scenario={}): {}",
731                recording.seed, recording.scenario_name, detail
732            );
733        }
734
735        recording
736    }
737}
738
739/// Assert that two [`LabOutput`]s are frame-identical.
740///
741/// Compares frame counts and all checksums. Panics with a descriptive
742/// message on the first mismatch.
743pub fn assert_outputs_match(a: &LabOutput, b: &LabOutput) {
744    assert_eq!(
745        a.frame_count, b.frame_count,
746        "frame count mismatch: a={}, b={}",
747        a.frame_count, b.frame_count
748    );
749    for (i, (fa, fb)) in a
750        .frame_records
751        .iter()
752        .zip(b.frame_records.iter())
753        .enumerate()
754    {
755        assert_eq!(
756            fa.checksum, fb.checksum,
757            "frame {i} checksum mismatch: a={:016x}, b={:016x}",
758            fa.checksum, fb.checksum
759        );
760    }
761}
762
763// ============================================================================
764// FNV-1a checksum (matches trace_replay.rs)
765// ============================================================================
766
767const FNV_OFFSET: u64 = 0xcbf29ce484222325;
768const FNV_PRIME: u64 = 0x100000001b3;
769
770/// FNV-1a checksum over buffer cell data.
771fn fnv1a_buffer(buf: &Buffer) -> u64 {
772    let mut hash = FNV_OFFSET;
773    for y in 0..buf.height() {
774        for x in 0..buf.width() {
775            if let Some(cell) = buf.get(x, y) {
776                if cell.is_continuation() {
777                    hash ^= 0x01u64;
778                    hash = hash.wrapping_mul(FNV_PRIME);
779                    continue;
780                }
781                if cell.is_empty() {
782                    hash ^= 0x00u64;
783                    hash = hash.wrapping_mul(FNV_PRIME);
784                } else if let Some(c) = cell.content.as_char() {
785                    hash ^= 0x02u64;
786                    hash = hash.wrapping_mul(FNV_PRIME);
787                    for b in (c as u32).to_le_bytes() {
788                        hash ^= b as u64;
789                        hash = hash.wrapping_mul(FNV_PRIME);
790                    }
791                } else {
792                    // grapheme reference
793                    hash ^= 0x03u64;
794                    hash = hash.wrapping_mul(FNV_PRIME);
795                }
796                // fg (PackedRgba inner u32)
797                hash ^= cell.fg.0 as u64;
798                hash = hash.wrapping_mul(FNV_PRIME);
799                // bg
800                hash ^= cell.bg.0 as u64;
801                hash = hash.wrapping_mul(FNV_PRIME);
802                // attrs: combine flags (u8) and link_id (u32)
803                hash ^= cell.attrs.flags().bits() as u64;
804                hash = hash.wrapping_mul(FNV_PRIME);
805                hash ^= cell.attrs.link_id() as u64;
806                hash = hash.wrapping_mul(FNV_PRIME);
807            }
808        }
809    }
810    hash
811}
812
813/// Human-readable label for an event kind.
814fn event_kind_label(event: &Event) -> String {
815    match event {
816        Event::Key(k) => format!("key:{:?}", k.code),
817        Event::Resize { width, height } => format!("resize:{width}x{height}"),
818        Event::Mouse(m) => format!("mouse:{:?}", m.kind),
819        Event::Tick => "tick".to_string(),
820        _ => "other".to_string(),
821    }
822}
823
824// ============================================================================
825// Tests
826// ============================================================================
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831    use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
832    use ftui_render::frame::Frame;
833
834    // ── Test model ───────────────────────────────────────────────────
835
836    struct Counter {
837        value: i32,
838    }
839
840    #[derive(Debug)]
841    enum CounterMsg {
842        Increment,
843        Decrement,
844        Tick,
845        Quit,
846    }
847
848    impl From<Event> for CounterMsg {
849        fn from(event: Event) -> Self {
850            match event {
851                Event::Key(k) if k.code == KeyCode::Char('+') => CounterMsg::Increment,
852                Event::Key(k) if k.code == KeyCode::Char('-') => CounterMsg::Decrement,
853                Event::Key(k) if k.code == KeyCode::Char('q') => CounterMsg::Quit,
854                Event::Tick => CounterMsg::Tick,
855                _ => CounterMsg::Tick,
856            }
857        }
858    }
859
860    impl Model for Counter {
861        type Message = CounterMsg;
862
863        fn init(&mut self) -> ftui_runtime::program::Cmd<Self::Message> {
864            ftui_runtime::program::Cmd::none()
865        }
866
867        fn update(&mut self, msg: Self::Message) -> ftui_runtime::program::Cmd<Self::Message> {
868            match msg {
869                CounterMsg::Increment => {
870                    self.value += 1;
871                    ftui_runtime::program::Cmd::none()
872                }
873                CounterMsg::Decrement => {
874                    self.value -= 1;
875                    ftui_runtime::program::Cmd::none()
876                }
877                CounterMsg::Tick => ftui_runtime::program::Cmd::none(),
878                CounterMsg::Quit => ftui_runtime::program::Cmd::quit(),
879            }
880        }
881
882        fn view(&self, frame: &mut Frame) {
883            let text = format!("Count: {}", self.value);
884            for (i, c) in text.chars().enumerate() {
885                if (i as u16) < frame.width() {
886                    use ftui_render::cell::Cell;
887                    frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
888                }
889            }
890        }
891    }
892
893    fn key_event(c: char) -> Event {
894        Event::Key(KeyEvent {
895            code: KeyCode::Char(c),
896            modifiers: Modifiers::empty(),
897            kind: KeyEventKind::Press,
898        })
899    }
900
901    // ── Tests ────────────────────────────────────────────────────────
902
903    #[test]
904    fn run_scenario_basic() {
905        let config = LabConfig::new("test", "basic", 42).viewport(20, 5);
906        let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
907            s.init();
908            s.send(CounterMsg::Increment);
909            s.send(CounterMsg::Increment);
910            s.capture_frame();
911        });
912
913        assert_eq!(run.output.frame_count, 1);
914        assert!(run.result.deterministic);
915        assert_eq!(run.result.seed, 42);
916    }
917
918    #[test]
919    fn deterministic_checksums_across_runs() {
920        let checksums1 = {
921            let config = LabConfig::new("test", "det_check", 99).viewport(20, 5);
922            let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
923                s.init();
924                for _ in 0..5 {
925                    s.send(CounterMsg::Increment);
926                    s.tick();
927                    s.capture_frame();
928                }
929            });
930            run.output
931                .frame_records
932                .iter()
933                .map(|f| f.checksum)
934                .collect::<Vec<_>>()
935        };
936
937        let checksums2 = {
938            let config = LabConfig::new("test", "det_check", 99).viewport(20, 5);
939            let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
940                s.init();
941                for _ in 0..5 {
942                    s.send(CounterMsg::Increment);
943                    s.tick();
944                    s.capture_frame();
945                }
946            });
947            run.output
948                .frame_records
949                .iter()
950                .map(|f| f.checksum)
951                .collect::<Vec<_>>()
952        };
953
954        assert_eq!(checksums1, checksums2);
955    }
956
957    #[test]
958    fn different_seeds_produce_different_metadata() {
959        let run1 = Lab::run_scenario(
960            LabConfig::new("test", "seed_diff", 1),
961            Counter { value: 0 },
962            |s| {
963                s.init();
964                s.capture_frame();
965            },
966        );
967
968        let run2 = Lab::run_scenario(
969            LabConfig::new("test", "seed_diff", 2),
970            Counter { value: 0 },
971            |s| {
972                s.init();
973                s.capture_frame();
974            },
975        );
976
977        assert_ne!(run1.result.run_id, run2.result.run_id);
978        assert_ne!(run1.result.seed, run2.result.seed);
979    }
980
981    #[test]
982    fn event_ordering_is_tracked() {
983        let config = LabConfig::new("test", "event_order", 42).viewport(20, 5);
984        let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
985            s.init();
986            s.send(CounterMsg::Increment);
987            s.tick();
988            s.inject_event(key_event('+'));
989            s.capture_frame();
990        });
991
992        assert_eq!(run.output.event_count, 3);
993        let log = &run.output.event_log;
994        assert_eq!(log[0].kind, "message");
995        assert_eq!(log[1].kind, "tick");
996        assert_eq!(log[2].kind, "key:Char('+')");
997        // Timestamps should be monotonically non-decreasing
998        for w in log.windows(2) {
999            assert!(w[1].timestamp_ms >= w[0].timestamp_ms);
1000        }
1001    }
1002
1003    #[test]
1004    fn tick_count_is_tracked() {
1005        let config = LabConfig::new("test", "tick_count", 42);
1006        let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
1007            s.init();
1008            s.tick();
1009            s.tick();
1010            s.tick();
1011            s.capture_frame();
1012        });
1013
1014        assert_eq!(run.output.tick_count, 3);
1015    }
1016
1017    #[test]
1018    fn model_access_works() {
1019        let config = LabConfig::new("test", "model_access", 42);
1020        Lab::run_scenario(config, Counter { value: 0 }, |s| {
1021            s.init();
1022            s.send(CounterMsg::Increment);
1023            s.send(CounterMsg::Increment);
1024            assert_eq!(s.model().value, 2);
1025        });
1026    }
1027
1028    #[test]
1029    fn quit_stops_session() {
1030        let config = LabConfig::new("test", "quit_test", 42);
1031        Lab::run_scenario(config, Counter { value: 0 }, |s| {
1032            s.init();
1033            s.send(CounterMsg::Increment);
1034            s.send(CounterMsg::Quit);
1035            assert!(!s.is_running());
1036        });
1037    }
1038
1039    #[test]
1040    fn custom_viewport_in_frame() {
1041        let config = LabConfig::new("test", "custom_viewport", 42).viewport(40, 10);
1042        let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
1043            s.init();
1044            s.capture_frame_at(100, 50);
1045            s.capture_frame(); // uses default 40x10
1046        });
1047
1048        assert_eq!(run.output.frame_count, 2);
1049    }
1050
1051    #[test]
1052    fn no_anomalies_in_normal_usage() {
1053        let config = LabConfig::new("test", "no_anomalies", 42);
1054        let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
1055            s.init();
1056            for _ in 0..10 {
1057                s.send(CounterMsg::Increment);
1058                s.tick();
1059                s.capture_frame();
1060            }
1061        });
1062
1063        assert_eq!(run.output.anomaly_count, 0);
1064    }
1065
1066    #[test]
1067    fn frame_checksums_change_with_state() {
1068        let config = LabConfig::new("test", "checksum_changes", 42).viewport(20, 5);
1069        let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
1070            s.init();
1071            s.capture_frame(); // value=0
1072            s.send(CounterMsg::Increment);
1073            s.capture_frame(); // value=1
1074        });
1075
1076        assert_eq!(run.output.frame_count, 2);
1077        let records = &run.output.frame_records;
1078        assert_ne!(
1079            records[0].checksum, records[1].checksum,
1080            "different model states should produce different checksums"
1081        );
1082    }
1083
1084    #[test]
1085    fn assert_deterministic_with_custom_scenario() {
1086        let config = LabConfig::new("test", "det_custom", 42).viewport(20, 5);
1087        let output = Lab::assert_deterministic_with(
1088            config,
1089            || Counter { value: 0 },
1090            |s| {
1091                s.init();
1092                for i in 0..5 {
1093                    if i % 2 == 0 {
1094                        s.send(CounterMsg::Increment);
1095                    } else {
1096                        s.send(CounterMsg::Decrement);
1097                    }
1098                    s.capture_frame();
1099                }
1100            },
1101        );
1102        assert_eq!(output.frame_count, 5);
1103    }
1104
1105    #[test]
1106    fn inject_events_batch() {
1107        let config = LabConfig::new("test", "inject_batch", 42).viewport(20, 5);
1108        let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
1109            s.init();
1110            s.inject_events(&[key_event('+'), key_event('+'), key_event('-')]);
1111            s.capture_frame();
1112        });
1113
1114        assert_eq!(run.output.event_count, 3);
1115    }
1116
1117    #[test]
1118    fn log_info_and_warn_accessible() {
1119        let config = LabConfig::new("test", "logging", 42);
1120        Lab::run_scenario(config, Counter { value: 0 }, |s| {
1121            s.init();
1122            s.log_info("custom.event", &[("key", JsonValue::str("value"))]);
1123            s.log_warn("test_anomaly", "simulated warning");
1124        });
1125        // Verify these don't panic.
1126    }
1127
1128    #[test]
1129    fn fnv1a_buffer_deterministic() {
1130        let buf1 = {
1131            let mut b = Buffer::new(5, 3);
1132            b.set_raw(0, 0, ftui_render::cell::Cell::from_char('A'));
1133            b.set_raw(1, 0, ftui_render::cell::Cell::from_char('B'));
1134            b
1135        };
1136        let buf2 = {
1137            let mut b = Buffer::new(5, 3);
1138            b.set_raw(0, 0, ftui_render::cell::Cell::from_char('A'));
1139            b.set_raw(1, 0, ftui_render::cell::Cell::from_char('B'));
1140            b
1141        };
1142        assert_eq!(fnv1a_buffer(&buf1), fnv1a_buffer(&buf2));
1143
1144        // Different content → different checksum
1145        let buf3 = {
1146            let mut b = Buffer::new(5, 3);
1147            b.set_raw(0, 0, ftui_render::cell::Cell::from_char('X'));
1148            b
1149        };
1150        assert_ne!(fnv1a_buffer(&buf1), fnv1a_buffer(&buf3));
1151    }
1152
1153    #[test]
1154    fn multi_seed_determinism_100_seeds() {
1155        for seed in 0..100 {
1156            let checksums1 = {
1157                let config = LabConfig::new("test", "multi_seed", seed).viewport(20, 5);
1158                let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
1159                    s.init();
1160                    s.send(CounterMsg::Increment);
1161                    s.tick();
1162                    s.capture_frame();
1163                });
1164                run.output
1165                    .frame_records
1166                    .iter()
1167                    .map(|f| f.checksum)
1168                    .collect::<Vec<_>>()
1169            };
1170            let checksums2 = {
1171                let config = LabConfig::new("test", "multi_seed", seed).viewport(20, 5);
1172                let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
1173                    s.init();
1174                    s.send(CounterMsg::Increment);
1175                    s.tick();
1176                    s.capture_frame();
1177                });
1178                run.output
1179                    .frame_records
1180                    .iter()
1181                    .map(|f| f.checksum)
1182                    .collect::<Vec<_>>()
1183            };
1184            assert_eq!(
1185                checksums1, checksums2,
1186                "seed {seed}: checksums diverged between runs"
1187            );
1188        }
1189    }
1190
1191    #[test]
1192    fn scenario_metadata_is_correct() {
1193        let config = LabConfig::new("meta_test", "my_scenario", 777)
1194            .viewport(40, 10)
1195            .time_step_ms(8);
1196        let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
1197            s.init();
1198            s.tick();
1199            s.capture_frame();
1200        });
1201
1202        assert_eq!(run.result.scenario_name, "my_scenario");
1203        assert_eq!(run.result.seed, 777);
1204        assert!(run.result.deterministic);
1205        assert!(run.result.run_total >= 1);
1206    }
1207
1208    #[test]
1209    fn event_timestamps_advance_with_time_step() {
1210        let config = LabConfig::new("test", "ts_advance", 42).time_step_ms(100);
1211        let run = Lab::run_scenario(config, Counter { value: 0 }, |s| {
1212            s.init();
1213            s.send(CounterMsg::Increment); // consumes 1 now_ms() call
1214            s.send(CounterMsg::Increment); // consumes 1 now_ms() call
1215            s.send(CounterMsg::Increment); // consumes 1 now_ms() call
1216        });
1217
1218        let log = &run.output.event_log;
1219        assert_eq!(log.len(), 3);
1220        // Each send() calls now_ms() which advances the clock by time_step_ms
1221        // DeterminismFixture::now_ms() returns fetch_add(step) + step
1222        // So first call returns step, second returns 2*step, etc.
1223        assert!(log[0].timestamp_ms <= log[1].timestamp_ms);
1224        assert!(log[1].timestamp_ms <= log[2].timestamp_ms);
1225    }
1226
1227    #[test]
1228    fn session_now_ms_advances() {
1229        let config = LabConfig::new("test", "now_ms", 42).time_step_ms(50);
1230        Lab::run_scenario(config, Counter { value: 0 }, |s| {
1231            s.init();
1232            let t0 = s.now_ms();
1233            let t1 = s.now_ms();
1234            assert!(t1 > t0, "now_ms should advance: t0={t0}, t1={t1}");
1235        });
1236    }
1237
1238    #[test]
1239    fn command_log_accessible() {
1240        let config = LabConfig::new("test", "cmd_log", 42);
1241        Lab::run_scenario(config, Counter { value: 0 }, |s| {
1242            s.init();
1243            s.send(CounterMsg::Increment);
1244            s.send(CounterMsg::Quit);
1245            let log = s.command_log();
1246            assert!(!log.is_empty());
1247        });
1248    }
1249
1250    // ── Recording / Replay tests ─────────────────────────────────────
1251
1252    #[test]
1253    fn record_captures_frame_checksums() {
1254        let config = LabConfig::new("test", "record_basic", 42).viewport(20, 5);
1255        let recording = Lab::record(config, Counter { value: 0 }, |s| {
1256            s.init();
1257            s.send(CounterMsg::Increment);
1258            s.capture_frame();
1259            s.send(CounterMsg::Increment);
1260            s.capture_frame();
1261        });
1262
1263        assert_eq!(recording.frame_records.len(), 2);
1264        assert_eq!(recording.seed, 42);
1265        assert_eq!(recording.scenario_name, "record_basic");
1266        assert!(!recording.run_id.is_empty());
1267    }
1268
1269    #[test]
1270    fn record_captures_event_log() {
1271        let config = LabConfig::new("test", "record_events", 42);
1272        let recording = Lab::record(config, Counter { value: 0 }, |s| {
1273            s.init();
1274            s.send(CounterMsg::Increment);
1275            s.tick();
1276            s.inject_event(key_event('+'));
1277        });
1278
1279        assert_eq!(recording.event_log.len(), 3);
1280        assert_eq!(recording.tick_count, 1);
1281    }
1282
1283    #[test]
1284    fn replay_matches_recording() {
1285        let config = LabConfig::new("test", "replay_match", 42).viewport(20, 5);
1286        let recording = Lab::record(config, Counter { value: 0 }, |s| {
1287            s.init();
1288            for _ in 0..5 {
1289                s.send(CounterMsg::Increment);
1290                s.tick();
1291                s.capture_frame();
1292            }
1293        });
1294
1295        let result = Lab::replay(&recording, Counter { value: 0 }, |s| {
1296            s.init();
1297            for _ in 0..5 {
1298                s.send(CounterMsg::Increment);
1299                s.tick();
1300                s.capture_frame();
1301            }
1302        });
1303
1304        assert!(result.matched, "replay should match recording");
1305        assert_eq!(result.frames_compared, 5);
1306        assert!(result.first_divergence.is_none());
1307        assert!(result.divergence_detail.is_none());
1308    }
1309
1310    #[test]
1311    fn replay_detects_frame_count_mismatch() {
1312        let config = LabConfig::new("test", "replay_count_diff", 42).viewport(20, 5);
1313        let recording = Lab::record(config, Counter { value: 0 }, |s| {
1314            s.init();
1315            s.capture_frame();
1316            s.capture_frame();
1317            s.capture_frame();
1318        });
1319
1320        // Replay with fewer frames
1321        let result = Lab::replay(&recording, Counter { value: 0 }, |s| {
1322            s.init();
1323            s.capture_frame();
1324        });
1325
1326        assert!(!result.matched);
1327        assert!(result.first_divergence.is_some());
1328        let detail = result.divergence_detail.unwrap();
1329        assert!(
1330            detail.contains("frame count mismatch"),
1331            "expected frame count mismatch message, got: {detail}"
1332        );
1333    }
1334
1335    #[test]
1336    fn replay_detects_checksum_mismatch() {
1337        let config = LabConfig::new("test", "replay_checksum_diff", 42).viewport(20, 5);
1338        let recording = Lab::record(config, Counter { value: 0 }, |s| {
1339            s.init();
1340            s.send(CounterMsg::Increment); // value=1
1341            s.capture_frame();
1342        });
1343
1344        // Replay with different state → different checksum
1345        let result = Lab::replay(&recording, Counter { value: 0 }, |s| {
1346            s.init();
1347            s.send(CounterMsg::Increment);
1348            s.send(CounterMsg::Increment); // value=2 (diverges)
1349            s.capture_frame();
1350        });
1351
1352        assert!(!result.matched);
1353        assert_eq!(result.first_divergence, Some(0));
1354        let detail = result.divergence_detail.unwrap();
1355        assert!(
1356            detail.contains("checksum mismatch"),
1357            "expected checksum mismatch message, got: {detail}"
1358        );
1359    }
1360
1361    #[test]
1362    fn assert_replay_deterministic_passes() {
1363        let config = LabConfig::new("test", "replay_det", 42).viewport(20, 5);
1364        let recording = Lab::assert_replay_deterministic(
1365            config,
1366            || Counter { value: 0 },
1367            |s| {
1368                s.init();
1369                for _ in 0..5 {
1370                    s.send(CounterMsg::Increment);
1371                    s.tick();
1372                    s.capture_frame();
1373                }
1374            },
1375        );
1376
1377        assert_eq!(recording.frame_records.len(), 5);
1378    }
1379
1380    #[test]
1381    #[should_panic(expected = "replay diverged")]
1382    fn assert_replay_deterministic_panics_on_divergence() {
1383        // This test uses a model that behaves differently on each creation,
1384        // simulated by using different initial values.
1385        let call_count = std::sync::atomic::AtomicU32::new(0);
1386        let config = LabConfig::new("test", "replay_diverge", 42).viewport(20, 5);
1387
1388        Lab::assert_replay_deterministic(
1389            config,
1390            || {
1391                let n = call_count.fetch_add(1, Ordering::Relaxed);
1392                // First model starts at 0, second at 100 → different frames
1393                Counter {
1394                    value: (n * 100) as i32,
1395                }
1396            },
1397            |s| {
1398                s.init();
1399                s.capture_frame();
1400            },
1401        );
1402    }
1403
1404    #[test]
1405    fn recording_counters_increment() {
1406        let before_record = lab_recordings_total();
1407        let before_replay = lab_replays_total();
1408
1409        let config = LabConfig::new("test", "counters", 42).viewport(10, 3);
1410        let recording = Lab::record(config, Counter { value: 0 }, |s| {
1411            s.init();
1412            s.capture_frame();
1413        });
1414
1415        assert!(
1416            lab_recordings_total() > before_record,
1417            "lab_recordings_total should increment"
1418        );
1419
1420        Lab::replay(&recording, Counter { value: 0 }, |s| {
1421            s.init();
1422            s.capture_frame();
1423        });
1424
1425        assert!(
1426            lab_replays_total() > before_replay,
1427            "lab_replays_total should increment"
1428        );
1429    }
1430
1431    #[test]
1432    fn assert_outputs_match_passes_for_identical() {
1433        let config = LabConfig::new("test", "output_match", 42).viewport(20, 5);
1434        let run1 = Lab::run_scenario(config.clone(), Counter { value: 0 }, |s| {
1435            s.init();
1436            s.send(CounterMsg::Increment);
1437            s.capture_frame();
1438        });
1439        let run2 = Lab::run_scenario(config, Counter { value: 0 }, |s| {
1440            s.init();
1441            s.send(CounterMsg::Increment);
1442            s.capture_frame();
1443        });
1444
1445        assert_outputs_match(&run1.output, &run2.output);
1446    }
1447
1448    #[test]
1449    #[should_panic(expected = "checksum mismatch")]
1450    fn assert_outputs_match_panics_on_difference() {
1451        let config1 = LabConfig::new("test", "output_diff", 42).viewport(20, 5);
1452        let config2 = LabConfig::new("test", "output_diff", 42).viewport(20, 5);
1453        let run1 = Lab::run_scenario(config1, Counter { value: 0 }, |s| {
1454            s.init();
1455            s.capture_frame(); // value=0
1456        });
1457        let run2 = Lab::run_scenario(config2, Counter { value: 5 }, |s| {
1458            s.init();
1459            s.capture_frame(); // value=5
1460        });
1461
1462        assert_outputs_match(&run1.output, &run2.output);
1463    }
1464
1465    #[test]
1466    fn replay_100_seeds_all_match() {
1467        for seed in 0..100 {
1468            let config = LabConfig::new("test", "replay_100", seed).viewport(20, 5);
1469            let recording = Lab::record(config, Counter { value: 0 }, |s| {
1470                s.init();
1471                s.send(CounterMsg::Increment);
1472                s.tick();
1473                s.capture_frame();
1474            });
1475            let result = Lab::replay(&recording, Counter { value: 0 }, |s| {
1476                s.init();
1477                s.send(CounterMsg::Increment);
1478                s.tick();
1479                s.capture_frame();
1480            });
1481            assert!(
1482                result.matched,
1483                "seed {seed}: replay diverged from recording"
1484            );
1485        }
1486    }
1487
1488    #[test]
1489    fn recording_config_is_preserved() {
1490        let config = LabConfig::new("prefix123", "scenario456", 789)
1491            .viewport(100, 50)
1492            .time_step_ms(33);
1493        let recording = Lab::record(config, Counter { value: 0 }, |s| {
1494            s.init();
1495            s.capture_frame();
1496        });
1497
1498        assert_eq!(recording.config.prefix, "prefix123");
1499        assert_eq!(recording.config.scenario_name, "scenario456");
1500        assert_eq!(recording.config.seed, 789);
1501        assert_eq!(recording.config.viewport_width, 100);
1502        assert_eq!(recording.config.viewport_height, 50);
1503        assert_eq!(recording.config.time_step_ms, 33);
1504    }
1505
1506    #[test]
1507    fn replay_result_carries_replay_frames() {
1508        let config = LabConfig::new("test", "replay_frames", 42).viewport(20, 5);
1509        let recording = Lab::record(config, Counter { value: 0 }, |s| {
1510            s.init();
1511            s.send(CounterMsg::Increment);
1512            s.capture_frame();
1513            s.send(CounterMsg::Increment);
1514            s.capture_frame();
1515        });
1516        let result = Lab::replay(&recording, Counter { value: 0 }, |s| {
1517            s.init();
1518            s.send(CounterMsg::Increment);
1519            s.capture_frame();
1520            s.send(CounterMsg::Increment);
1521            s.capture_frame();
1522        });
1523
1524        assert!(result.matched);
1525        assert_eq!(result.replay_frame_records.len(), 2);
1526        // Replay frames should match recording frames
1527        for (rec, rep) in recording
1528            .frame_records
1529            .iter()
1530            .zip(result.replay_frame_records.iter())
1531        {
1532            assert_eq!(rec.checksum, rep.checksum);
1533        }
1534    }
1535}