1#![forbid(unsafe_code)]
2
3use 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
49static LAB_RECORDINGS_TOTAL: AtomicU64 = AtomicU64::new(0);
51static LAB_REPLAYS_TOTAL: AtomicU64 = AtomicU64::new(0);
53
54#[must_use]
56pub fn lab_recordings_total() -> u64 {
57 LAB_RECORDINGS_TOTAL.load(Ordering::Relaxed)
58}
59
60#[must_use]
62pub fn lab_replays_total() -> u64 {
63 LAB_REPLAYS_TOTAL.load(Ordering::Relaxed)
64}
65
66#[derive(Debug, Clone)]
72pub struct LabConfig {
73 pub prefix: String,
75 pub scenario_name: String,
77 pub seed: u64,
79 pub viewport_width: u16,
81 pub viewport_height: u16,
83 pub time_step_ms: u64,
85 pub log_frame_checksums: bool,
87}
88
89impl LabConfig {
90 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 #[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 #[must_use]
115 pub fn time_step_ms(mut self, ms: u64) -> Self {
116 self.time_step_ms = ms;
117 self
118 }
119
120 #[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#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct FrameRecord {
135 pub index: usize,
137 pub timestamp_ms: u64,
139 pub checksum: u64,
141}
142
143#[derive(Debug, Clone)]
145pub struct EventRecord {
146 pub timestamp_ms: u64,
148 pub sequence: u64,
150 pub kind: String,
152}
153
154pub 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 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 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 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 pub fn inject_events(&mut self, events: &[Event]) {
219 for event in events {
220 self.inject_event(event.clone());
221 }
222 }
223
224 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 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 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 pub fn model(&self) -> &M {
286 self.sim.model()
287 }
288
289 pub fn model_mut(&mut self) -> &mut M {
291 self.sim.model_mut()
292 }
293
294 pub fn is_running(&self) -> bool {
296 self.sim.is_running()
297 }
298
299 pub fn frame_records(&self) -> &[FrameRecord] {
301 &self.frame_records
302 }
303
304 pub fn event_log(&self) -> &[EventRecord] {
306 &self.event_log
307 }
308
309 pub fn tick_count(&self) -> u64 {
311 self.tick_count
312 }
313
314 pub fn anomaly_count(&self) -> u64 {
316 self.anomaly_count
317 }
318
319 pub fn frames(&self) -> &[Buffer] {
321 self.sim.frames()
322 }
323
324 pub fn last_frame(&self) -> Option<&Buffer> {
326 self.sim.last_frame()
327 }
328
329 pub fn logs(&self) -> &[String] {
331 self.sim.logs()
332 }
333
334 pub fn command_log(&self) -> &[ftui_runtime::simulator::CmdRecord] {
336 self.sim.command_log()
337 }
338
339 pub fn pool(&self) -> &ftui_render::grapheme_pool::GraphemePool {
341 self.sim.pool()
342 }
343
344 pub fn now_ms(&self) -> u64 {
346 self.logger.fixture().now_ms()
347 }
348
349 pub fn log_info(&self, event: &str, fields: &[(&str, JsonValue)]) {
351 self.logger.log(event, fields);
352 }
353
354 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 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#[derive(Debug, Clone)]
405pub struct LabOutput {
406 pub frame_count: usize,
408 pub frame_records: Vec<FrameRecord>,
410 pub event_count: usize,
412 pub event_log: Vec<EventRecord>,
414 pub tick_count: u64,
416 pub anomaly_count: u64,
418}
419
420pub struct Lab;
429
430impl Lab {
431 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, config.time_step_ms,
450 );
451
452 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 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#[derive(Debug, Clone)]
535pub struct Recording {
536 pub config: LabConfig,
538 pub scenario_name: String,
540 pub seed: u64,
542 pub frame_records: Vec<FrameRecord>,
544 pub event_log: Vec<EventRecord>,
546 pub tick_count: u64,
548 pub run_id: String,
550}
551
552#[derive(Debug, Clone)]
554pub struct ReplayResult {
555 pub matched: bool,
557 pub replay_frame_records: Vec<FrameRecord>,
559 pub first_divergence: Option<usize>,
561 pub divergence_detail: Option<String>,
563 pub frames_compared: usize,
565}
566
567impl Lab {
568 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 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 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 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 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
739pub 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
763const FNV_OFFSET: u64 = 0xcbf29ce484222325;
768const FNV_PRIME: u64 = 0x100000001b3;
769
770fn 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 hash ^= 0x03u64;
794 hash = hash.wrapping_mul(FNV_PRIME);
795 }
796 hash ^= cell.fg.0 as u64;
798 hash = hash.wrapping_mul(FNV_PRIME);
799 hash ^= cell.bg.0 as u64;
801 hash = hash.wrapping_mul(FNV_PRIME);
802 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
813fn 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#[cfg(test)]
829mod tests {
830 use super::*;
831 use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
832 use ftui_render::frame::Frame;
833
834 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 #[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 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(); });
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(); s.send(CounterMsg::Increment);
1073 s.capture_frame(); });
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 }
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 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); s.send(CounterMsg::Increment); s.send(CounterMsg::Increment); });
1217
1218 let log = &run.output.event_log;
1219 assert_eq!(log.len(), 3);
1220 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 #[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 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); s.capture_frame();
1342 });
1343
1344 let result = Lab::replay(&recording, Counter { value: 0 }, |s| {
1346 s.init();
1347 s.send(CounterMsg::Increment);
1348 s.send(CounterMsg::Increment); 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 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 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(); });
1457 let run2 = Lab::run_scenario(config2, Counter { value: 5 }, |s| {
1458 s.init();
1459 s.capture_frame(); });
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 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}