1#![forbid(unsafe_code)]
2
3use core::time::Duration;
48
49use ftui_core::event::{
50 ClipboardEvent, ClipboardSource, Event, KeyCode, KeyEvent, KeyEventKind, Modifiers,
51 MouseButton, MouseEvent, MouseEventKind, PasteEvent,
52};
53use ftui_runtime::render_trace::checksum_buffer;
54
55use crate::WebBackendError;
56use crate::step_program::{StepProgram, StepResult};
57
58pub const SCHEMA_VERSION: &str = "golden-trace-v1";
60
61const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
63const FNV_PRIME: u64 = 0x100000001b3;
64
65fn fnv1a64_bytes(mut hash: u64, bytes: &[u8]) -> u64 {
66 for &b in bytes {
67 hash ^= b as u64;
68 hash = hash.wrapping_mul(FNV_PRIME);
69 }
70 hash
71}
72
73fn fnv1a64_u64(hash: u64, v: u64) -> u64 {
74 fnv1a64_bytes(hash, &v.to_le_bytes())
75}
76
77fn fnv1a64_pair(prev: u64, next: u64) -> u64 {
78 let hash = FNV_OFFSET_BASIS;
79 let hash = fnv1a64_u64(hash, prev);
80 fnv1a64_u64(hash, next)
81}
82
83#[derive(Debug, Clone, PartialEq)]
85pub enum TraceRecord {
86 Header {
88 seed: u64,
89 cols: u16,
90 rows: u16,
91 profile: String,
92 },
93 Input { ts_ns: u64, event: Event },
95 Resize { ts_ns: u64, cols: u16, rows: u16 },
97 Tick { ts_ns: u64 },
99 Frame {
101 frame_idx: u64,
102 ts_ns: u64,
103 checksum: u64,
104 checksum_chain: u64,
105 },
106 Summary {
108 total_frames: u64,
109 final_checksum_chain: u64,
110 },
111}
112
113#[derive(Debug, Clone)]
115pub struct SessionTrace {
116 pub records: Vec<TraceRecord>,
117}
118
119impl SessionTrace {
120 pub fn frame_count(&self) -> u64 {
122 self.records
123 .iter()
124 .filter(|r| matches!(r, TraceRecord::Frame { .. }))
125 .count() as u64
126 }
127
128 pub fn final_checksum_chain(&self) -> Option<u64> {
130 self.records.iter().rev().find_map(|r| match r {
131 TraceRecord::Summary {
132 final_checksum_chain,
133 ..
134 } => Some(*final_checksum_chain),
135 _ => None,
136 })
137 }
138
139 pub fn validate(&self) -> Result<(), TraceValidationError> {
147 if self.records.is_empty() {
148 return Err(TraceValidationError::EmptyTrace);
149 }
150
151 let mut header_count: usize = 0;
152 let mut summary: Option<(usize, u64, u64)> = None;
153 let mut expected_frame_idx: u64 = 0;
154 let mut frame_count: u64 = 0;
155 let mut last_checksum_chain: u64 = 0;
156 let mut last_ts_ns: Option<u64> = None;
157
158 let mut validate_ts =
159 |ts_ns: u64, record_index: usize| -> Result<(), TraceValidationError> {
160 if let Some(previous) = last_ts_ns
161 && ts_ns < previous
162 {
163 return Err(TraceValidationError::TimestampRegression {
164 previous,
165 current: ts_ns,
166 record_index,
167 });
168 }
169 last_ts_ns = Some(ts_ns);
170 Ok(())
171 };
172
173 for (idx, record) in self.records.iter().enumerate() {
174 match record {
175 TraceRecord::Header { .. } => {
176 if summary.is_some() {
177 let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
178 return Err(TraceValidationError::SummaryNotLast {
179 summary_index: summary_idx,
180 });
181 }
182 header_count += 1;
183 }
184 TraceRecord::Summary {
185 total_frames,
186 final_checksum_chain,
187 } => {
188 if summary.is_some() {
189 return Err(TraceValidationError::MultipleSummaries);
190 }
191 summary = Some((idx, *total_frames, *final_checksum_chain));
192 }
193 TraceRecord::Frame {
194 frame_idx,
195 ts_ns,
196 checksum_chain,
197 ..
198 } => {
199 validate_ts(*ts_ns, idx)?;
200 if summary.is_some() {
201 let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
202 return Err(TraceValidationError::SummaryNotLast {
203 summary_index: summary_idx,
204 });
205 }
206 if *frame_idx != expected_frame_idx {
207 return Err(TraceValidationError::FrameIndexMismatch {
208 expected: expected_frame_idx,
209 actual: *frame_idx,
210 });
211 }
212 expected_frame_idx = expected_frame_idx.saturating_add(1);
213 frame_count = frame_count.saturating_add(1);
214 last_checksum_chain = *checksum_chain;
215 }
216 TraceRecord::Input { ts_ns, .. } => {
217 validate_ts(*ts_ns, idx)?;
218 if summary.is_some() {
219 let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
220 return Err(TraceValidationError::SummaryNotLast {
221 summary_index: summary_idx,
222 });
223 }
224 }
225 TraceRecord::Resize { ts_ns, .. } => {
226 validate_ts(*ts_ns, idx)?;
227 if summary.is_some() {
228 let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
229 return Err(TraceValidationError::SummaryNotLast {
230 summary_index: summary_idx,
231 });
232 }
233 }
234 TraceRecord::Tick { ts_ns } => {
235 validate_ts(*ts_ns, idx)?;
236 if summary.is_some() {
237 let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
238 return Err(TraceValidationError::SummaryNotLast {
239 summary_index: summary_idx,
240 });
241 }
242 }
243 }
244 }
245
246 if header_count == 0 {
247 return Err(TraceValidationError::MissingHeader);
248 }
249 if header_count > 1 {
250 return Err(TraceValidationError::MultipleHeaders);
251 }
252 if !matches!(self.records.first(), Some(TraceRecord::Header { .. })) {
253 return Err(TraceValidationError::HeaderNotFirst);
254 }
255
256 let Some((summary_idx, summary_frames, summary_chain)) = summary else {
257 return Err(TraceValidationError::MissingSummary);
258 };
259 if summary_idx != self.records.len().saturating_sub(1) {
260 return Err(TraceValidationError::SummaryNotLast {
261 summary_index: summary_idx,
262 });
263 }
264 if summary_frames != frame_count {
265 return Err(TraceValidationError::SummaryFrameCountMismatch {
266 expected: frame_count,
267 actual: summary_frames,
268 });
269 }
270 if summary_chain != last_checksum_chain {
271 return Err(TraceValidationError::SummaryChecksumChainMismatch {
272 expected: last_checksum_chain,
273 actual: summary_chain,
274 });
275 }
276
277 Ok(())
278 }
279}
280
281pub struct SessionRecorder<M: ftui_runtime::program::Model> {
287 program: StepProgram<M>,
288 records: Vec<TraceRecord>,
289 checksum_chain: u64,
290 current_ts_ns: u64,
291}
292
293impl<M: ftui_runtime::program::Model> SessionRecorder<M> {
294 #[must_use]
296 pub fn new(model: M, width: u16, height: u16, seed: u64) -> Self {
297 let program = StepProgram::new(model, width, height);
298 let records = vec![TraceRecord::Header {
299 seed,
300 cols: width,
301 rows: height,
302 profile: "modern".to_string(),
303 }];
304 Self {
305 program,
306 records,
307 checksum_chain: 0,
308 current_ts_ns: 0,
309 }
310 }
311
312 pub fn init(&mut self) -> Result<(), WebBackendError> {
314 self.program.init()?;
315 self.record_frame();
316 Ok(())
317 }
318
319 pub fn push_event(&mut self, ts_ns: u64, event: Event) {
321 self.current_ts_ns = ts_ns;
322 self.records.push(TraceRecord::Input {
323 ts_ns,
324 event: event.clone(),
325 });
326 self.program.push_event(event);
327 }
328
329 pub fn resize(&mut self, ts_ns: u64, width: u16, height: u16) {
331 self.current_ts_ns = ts_ns;
332 self.records.push(TraceRecord::Resize {
333 ts_ns,
334 cols: width,
335 rows: height,
336 });
337 self.program.resize(width, height);
338 }
339
340 pub fn advance_time(&mut self, ts_ns: u64, dt: Duration) {
342 self.current_ts_ns = ts_ns;
343 self.records.push(TraceRecord::Tick { ts_ns });
344 self.program.advance_time(dt);
345 }
346
347 pub fn step(&mut self) -> Result<StepResult, WebBackendError> {
349 let result = self.program.step()?;
350 if result.rendered {
351 self.record_frame();
352 }
353 Ok(result)
354 }
355
356 pub fn finish(mut self) -> SessionTrace {
358 let total_frames = self
359 .records
360 .iter()
361 .filter(|r| matches!(r, TraceRecord::Frame { .. }))
362 .count() as u64;
363 self.records.push(TraceRecord::Summary {
364 total_frames,
365 final_checksum_chain: self.checksum_chain,
366 });
367 SessionTrace {
368 records: self.records,
369 }
370 }
371
372 pub fn program(&self) -> &StepProgram<M> {
374 &self.program
375 }
376
377 pub fn program_mut(&mut self) -> &mut StepProgram<M> {
379 &mut self.program
380 }
381
382 fn record_frame(&mut self) {
383 let outputs = self.program.outputs();
384 if let Some(buf) = &outputs.last_buffer {
385 let checksum = checksum_buffer(buf, self.program.pool());
386 let chain = fnv1a64_pair(self.checksum_chain, checksum);
387 self.records.push(TraceRecord::Frame {
388 frame_idx: self.program.frame_idx().saturating_sub(1),
389 ts_ns: self.current_ts_ns,
390 checksum,
391 checksum_chain: chain,
392 });
393 self.checksum_chain = chain;
394 }
395 }
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct ReplayResult {
401 pub total_frames: u64,
403 pub final_checksum_chain: u64,
405 pub first_mismatch: Option<ReplayMismatch>,
407}
408
409impl ReplayResult {
410 #[must_use]
412 pub fn ok(&self) -> bool {
413 self.first_mismatch.is_none()
414 }
415}
416
417#[derive(Debug, Clone, PartialEq, Eq)]
419pub struct ReplayMismatch {
420 pub frame_idx: u64,
422 pub expected: u64,
424 pub actual: u64,
426}
427
428#[derive(Debug, Clone, PartialEq, Eq)]
430pub enum ReplayError {
431 MissingHeader,
433 InvalidTrace(TraceValidationError),
435 Backend(WebBackendError),
437}
438
439impl core::fmt::Display for ReplayError {
440 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
441 match self {
442 Self::MissingHeader => write!(f, "trace missing header record"),
443 Self::InvalidTrace(e) => write!(f, "invalid trace: {e}"),
444 Self::Backend(e) => write!(f, "backend error: {e}"),
445 }
446 }
447}
448
449impl std::error::Error for ReplayError {}
450
451impl From<WebBackendError> for ReplayError {
452 fn from(e: WebBackendError) -> Self {
453 Self::Backend(e)
454 }
455}
456
457pub fn replay<M: ftui_runtime::program::Model>(
466 model: M,
467 trace: &SessionTrace,
468) -> Result<ReplayResult, ReplayError> {
469 let (cols, rows) = trace
471 .records
472 .first()
473 .and_then(|r| match r {
474 TraceRecord::Header { cols, rows, .. } => Some((*cols, *rows)),
475 _ => None,
476 })
477 .ok_or(ReplayError::MissingHeader)?;
478 trace.validate().map_err(ReplayError::InvalidTrace)?;
479
480 let mut program = StepProgram::new(model, cols, rows);
481 program.init()?;
482
483 let mut replay_frame_idx: u64 = 0;
484 let mut checksum_chain: u64 = 0;
485 let mut first_mismatch: Option<ReplayMismatch> = None;
486
487 for record in &trace.records {
491 match record {
492 TraceRecord::Input { event, .. } => {
493 program.push_event(event.clone());
494 }
495 TraceRecord::Resize { cols, rows, .. } => {
496 program.resize(*cols, *rows);
497 }
498 TraceRecord::Tick { ts_ns } => {
499 program.set_time(Duration::from_nanos(*ts_ns));
500 }
501 TraceRecord::Frame {
502 frame_idx: expected_idx,
503 checksum: expected_checksum,
504 ..
505 } => {
506 if replay_frame_idx > 0 {
509 program.step()?;
510 }
511
512 let outputs = program.outputs();
514 if let Some(buf) = &outputs.last_buffer {
515 let actual = checksum_buffer(buf, program.pool());
516 checksum_chain = fnv1a64_pair(checksum_chain, actual);
517 if actual != *expected_checksum && first_mismatch.is_none() {
518 first_mismatch = Some(ReplayMismatch {
519 frame_idx: *expected_idx,
520 expected: *expected_checksum,
521 actual,
522 });
523 }
524 }
525 replay_frame_idx += 1;
526 }
527 TraceRecord::Header { .. } | TraceRecord::Summary { .. } => {}
528 }
529 }
530
531 Ok(ReplayResult {
532 total_frames: replay_frame_idx,
533 final_checksum_chain: checksum_chain,
534 first_mismatch,
535 })
536}
537
538fn json_escape(input: &str) -> String {
541 let mut out = String::with_capacity(input.len() + 8);
542 for ch in input.chars() {
543 match ch {
544 '"' => out.push_str("\\\""),
545 '\\' => out.push_str("\\\\"),
546 '\n' => out.push_str("\\n"),
547 '\r' => out.push_str("\\r"),
548 '\t' => out.push_str("\\t"),
549 c if c.is_control() => {
550 use core::fmt::Write as _;
551 let _ = write!(out, "\\u{:04x}", c as u32);
552 }
553 c => out.push(c),
554 }
555 }
556 out
557}
558
559fn event_to_json(event: &Event) -> String {
560 match event {
561 Event::Key(k) => {
562 let code = key_code_to_str(k.code);
563 let mods = k.modifiers.bits();
564 let kind = key_event_kind_to_str(k.kind);
565 format!(
566 r#"{{"kind":"key","code":"{}","modifiers":{},"event_kind":"{}"}}"#,
567 json_escape(&code),
568 mods,
569 kind
570 )
571 }
572 Event::Mouse(m) => {
573 let kind = mouse_event_kind_to_str(m.kind);
574 let mods = m.modifiers.bits();
575 format!(
576 r#"{{"kind":"mouse","mouse_kind":"{}","x":{},"y":{},"modifiers":{}}}"#,
577 kind, m.x, m.y, mods
578 )
579 }
580 Event::Resize { width, height } => {
581 format!(
582 r#"{{"kind":"resize","width":{},"height":{}}}"#,
583 width, height
584 )
585 }
586 Event::Paste(p) => {
587 format!(
588 r#"{{"kind":"paste","text":"{}","bracketed":{}}}"#,
589 json_escape(&p.text),
590 p.bracketed
591 )
592 }
593 Event::Focus(gained) => {
594 format!(r#"{{"kind":"focus","gained":{}}}"#, gained)
595 }
596 Event::Clipboard(c) => {
597 let source = clipboard_source_to_str(c.source);
598 format!(
599 r#"{{"kind":"clipboard","content":"{}","source":"{}"}}"#,
600 json_escape(&c.content),
601 source
602 )
603 }
604 Event::Tick => r#"{"kind":"tick"}"#.to_string(),
605 }
606}
607
608fn key_code_to_str(code: KeyCode) -> String {
609 match code {
610 KeyCode::Char(c) => format!("char:{c}"),
611 KeyCode::Enter => "enter".to_string(),
612 KeyCode::Escape => "escape".to_string(),
613 KeyCode::Backspace => "backspace".to_string(),
614 KeyCode::Tab => "tab".to_string(),
615 KeyCode::BackTab => "backtab".to_string(),
616 KeyCode::Delete => "delete".to_string(),
617 KeyCode::Insert => "insert".to_string(),
618 KeyCode::Home => "home".to_string(),
619 KeyCode::End => "end".to_string(),
620 KeyCode::PageUp => "pageup".to_string(),
621 KeyCode::PageDown => "pagedown".to_string(),
622 KeyCode::Up => "up".to_string(),
623 KeyCode::Down => "down".to_string(),
624 KeyCode::Left => "left".to_string(),
625 KeyCode::Right => "right".to_string(),
626 KeyCode::F(n) => format!("f:{n}"),
627 KeyCode::Null => "null".to_string(),
628 KeyCode::MediaPlayPause => "media_play_pause".to_string(),
629 KeyCode::MediaStop => "media_stop".to_string(),
630 KeyCode::MediaNextTrack => "media_next".to_string(),
631 KeyCode::MediaPrevTrack => "media_prev".to_string(),
632 }
633}
634
635fn key_event_kind_to_str(kind: KeyEventKind) -> &'static str {
636 match kind {
637 KeyEventKind::Press => "press",
638 KeyEventKind::Repeat => "repeat",
639 KeyEventKind::Release => "release",
640 }
641}
642
643fn mouse_event_kind_to_str(kind: MouseEventKind) -> &'static str {
644 match kind {
645 MouseEventKind::Down(MouseButton::Left) => "down_left",
646 MouseEventKind::Down(MouseButton::Right) => "down_right",
647 MouseEventKind::Down(MouseButton::Middle) => "down_middle",
648 MouseEventKind::Up(MouseButton::Left) => "up_left",
649 MouseEventKind::Up(MouseButton::Right) => "up_right",
650 MouseEventKind::Up(MouseButton::Middle) => "up_middle",
651 MouseEventKind::Drag(MouseButton::Left) => "drag_left",
652 MouseEventKind::Drag(MouseButton::Right) => "drag_right",
653 MouseEventKind::Drag(MouseButton::Middle) => "drag_middle",
654 MouseEventKind::Moved => "moved",
655 MouseEventKind::ScrollUp => "scroll_up",
656 MouseEventKind::ScrollDown => "scroll_down",
657 MouseEventKind::ScrollLeft => "scroll_left",
658 MouseEventKind::ScrollRight => "scroll_right",
659 }
660}
661
662fn clipboard_source_to_str(source: ClipboardSource) -> &'static str {
663 match source {
664 ClipboardSource::Osc52 => "osc52",
665 ClipboardSource::Unknown => "unknown",
666 }
667}
668
669impl TraceRecord {
670 pub fn to_jsonl(&self) -> String {
672 match self {
673 TraceRecord::Header {
674 seed,
675 cols,
676 rows,
677 profile,
678 } => format!(
679 r#"{{"schema_version":"{}","event":"trace_header","seed":{},"cols":{},"rows":{},"env":{{"target":"web"}},"profile":"{}"}}"#,
680 SCHEMA_VERSION,
681 seed,
682 cols,
683 rows,
684 json_escape(profile)
685 ),
686 TraceRecord::Input { ts_ns, event } => format!(
687 r#"{{"schema_version":"{}","event":"input","ts_ns":{},"data":{}}}"#,
688 SCHEMA_VERSION,
689 ts_ns,
690 event_to_json(event)
691 ),
692 TraceRecord::Resize { ts_ns, cols, rows } => format!(
693 r#"{{"schema_version":"{}","event":"resize","ts_ns":{},"cols":{},"rows":{}}}"#,
694 SCHEMA_VERSION, ts_ns, cols, rows
695 ),
696 TraceRecord::Tick { ts_ns } => format!(
697 r#"{{"schema_version":"{}","event":"tick","ts_ns":{}}}"#,
698 SCHEMA_VERSION, ts_ns
699 ),
700 TraceRecord::Frame {
701 frame_idx,
702 ts_ns,
703 checksum,
704 checksum_chain,
705 } => format!(
706 r#"{{"schema_version":"{}","event":"frame","frame_idx":{},"ts_ns":{},"hash_algo":"fnv1a64","frame_hash":"{:016x}","checksum_chain":"{:016x}"}}"#,
707 SCHEMA_VERSION, frame_idx, ts_ns, checksum, checksum_chain
708 ),
709 TraceRecord::Summary {
710 total_frames,
711 final_checksum_chain,
712 } => format!(
713 r#"{{"schema_version":"{}","event":"trace_summary","total_frames":{},"final_checksum_chain":"{:016x}"}}"#,
714 SCHEMA_VERSION, total_frames, final_checksum_chain
715 ),
716 }
717 }
718}
719
720impl SessionTrace {
721 pub fn to_jsonl(&self) -> String {
723 let mut out = String::new();
724 for record in &self.records {
725 out.push_str(&record.to_jsonl());
726 out.push('\n');
727 }
728 out
729 }
730
731 pub fn from_jsonl(input: &str) -> Result<Self, TraceParseError> {
735 let mut records = Vec::new();
736 for (line_num, line) in input.lines().enumerate() {
737 let line = line.trim();
738 if line.is_empty() {
739 continue;
740 }
741 let record = parse_trace_line(line, line_num + 1)?;
742 records.push(record);
743 }
744 Ok(SessionTrace { records })
745 }
746
747 pub fn from_jsonl_validated(input: &str) -> Result<Self, TraceLoadError> {
749 let trace = Self::from_jsonl(input)?;
750 trace.validate()?;
751 Ok(trace)
752 }
753}
754
755#[derive(Debug, Clone, PartialEq, Eq)]
757pub struct TraceParseError {
758 pub line: usize,
759 pub message: String,
760}
761
762impl core::fmt::Display for TraceParseError {
763 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
764 write!(f, "line {}: {}", self.line, self.message)
765 }
766}
767
768impl std::error::Error for TraceParseError {}
769
770#[derive(Debug, Clone, PartialEq, Eq)]
772pub enum TraceValidationError {
773 EmptyTrace,
774 MissingHeader,
775 HeaderNotFirst,
776 MultipleHeaders,
777 MissingSummary,
778 MultipleSummaries,
779 SummaryNotLast {
780 summary_index: usize,
781 },
782 TimestampRegression {
783 previous: u64,
784 current: u64,
785 record_index: usize,
786 },
787 FrameIndexMismatch {
788 expected: u64,
789 actual: u64,
790 },
791 SummaryFrameCountMismatch {
792 expected: u64,
793 actual: u64,
794 },
795 SummaryChecksumChainMismatch {
796 expected: u64,
797 actual: u64,
798 },
799}
800
801impl core::fmt::Display for TraceValidationError {
802 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
803 match self {
804 Self::EmptyTrace => write!(f, "trace is empty"),
805 Self::MissingHeader => write!(f, "trace is missing header"),
806 Self::HeaderNotFirst => write!(f, "trace header is not the first record"),
807 Self::MultipleHeaders => write!(f, "trace contains multiple headers"),
808 Self::MissingSummary => write!(f, "trace is missing summary"),
809 Self::MultipleSummaries => write!(f, "trace contains multiple summaries"),
810 Self::SummaryNotLast { summary_index } => write!(
811 f,
812 "trace summary at index {} is not the final record",
813 summary_index
814 ),
815 Self::TimestampRegression {
816 previous,
817 current,
818 record_index,
819 } => write!(
820 f,
821 "timestamp regression at record {}: current ts_ns={} is less than previous ts_ns={}",
822 record_index, current, previous
823 ),
824 Self::FrameIndexMismatch { expected, actual } => {
825 write!(
826 f,
827 "frame index mismatch: expected {}, got {}",
828 expected, actual
829 )
830 }
831 Self::SummaryFrameCountMismatch { expected, actual } => write!(
832 f,
833 "summary frame-count mismatch: expected {}, got {}",
834 expected, actual
835 ),
836 Self::SummaryChecksumChainMismatch { expected, actual } => write!(
837 f,
838 "summary checksum-chain mismatch: expected {:016x}, got {:016x}",
839 expected, actual
840 ),
841 }
842 }
843}
844
845impl std::error::Error for TraceValidationError {}
846
847#[derive(Debug, Clone, PartialEq, Eq)]
849pub enum TraceLoadError {
850 Parse(TraceParseError),
851 Validation(TraceValidationError),
852}
853
854impl core::fmt::Display for TraceLoadError {
855 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
856 match self {
857 Self::Parse(e) => write!(f, "{e}"),
858 Self::Validation(e) => write!(f, "{e}"),
859 }
860 }
861}
862
863impl std::error::Error for TraceLoadError {}
864
865impl From<TraceParseError> for TraceLoadError {
866 fn from(value: TraceParseError) -> Self {
867 Self::Parse(value)
868 }
869}
870
871impl From<TraceValidationError> for TraceLoadError {
872 fn from(value: TraceValidationError) -> Self {
873 Self::Validation(value)
874 }
875}
876
877fn extract_str<'a>(json: &'a str, key: &str) -> Option<&'a str> {
880 let pattern = format!("\"{}\":\"", key);
881 let start = json.find(&pattern)? + pattern.len();
882 let rest = &json[start..];
883 let mut i = 0;
885 let bytes = rest.as_bytes();
886 while i < bytes.len() {
887 if bytes[i] == b'\\' {
888 i += 2; continue;
890 }
891 if bytes[i] == b'"' {
892 return Some(&rest[..i]);
893 }
894 i += 1;
895 }
896 None
897}
898
899fn extract_u64(json: &str, key: &str) -> Option<u64> {
900 let pattern = format!("\"{}\":", key);
901 let start = json.find(&pattern)? + pattern.len();
902 let rest = json[start..].trim_start();
903 let end = rest
904 .find(|c: char| !c.is_ascii_digit())
905 .unwrap_or(rest.len());
906 rest[..end].parse().ok()
907}
908
909fn extract_i64(json: &str, key: &str) -> Option<i64> {
910 let pattern = format!("\"{}\":", key);
911 let start = json.find(&pattern)? + pattern.len();
912 let rest = json[start..].trim_start();
913 let signed = rest.strip_prefix('-').is_some();
914 let digits = if signed { &rest[1..] } else { rest };
915 let end = digits
916 .find(|c: char| !c.is_ascii_digit())
917 .unwrap_or(digits.len());
918 if end == 0 {
919 return None;
920 }
921 let parsed: i64 = digits[..end].parse().ok()?;
922 Some(if signed { -parsed } else { parsed })
923}
924
925fn extract_u16(json: &str, key: &str) -> Option<u16> {
926 extract_u64(json, key).and_then(|v| u16::try_from(v).ok())
927}
928
929fn extract_bool(json: &str, key: &str) -> Option<bool> {
930 let pattern = format!("\"{}\":", key);
931 let start = json.find(&pattern)? + pattern.len();
932 let rest = json[start..].trim_start();
933 if rest.starts_with("true") {
934 Some(true)
935 } else if rest.starts_with("false") {
936 Some(false)
937 } else {
938 None
939 }
940}
941
942fn extract_hex_u64(json: &str, key: &str) -> Option<u64> {
943 let s = extract_str(json, key)?;
944 u64::from_str_radix(s, 16).ok()
945}
946
947fn extract_object<'a>(json: &'a str, key: &str) -> Option<&'a str> {
948 let pattern = format!("\"{}\":", key);
949 let start = json.find(&pattern)? + pattern.len();
950 let rest = json[start..].trim_start();
951 if !rest.starts_with('{') {
952 return None;
953 }
954 let mut depth = 0;
955 for (i, ch) in rest.char_indices() {
956 match ch {
957 '{' => depth += 1,
958 '}' => {
959 depth -= 1;
960 if depth == 0 {
961 return Some(&rest[..=i]);
962 }
963 }
964 _ => {}
965 }
966 }
967 None
968}
969
970fn json_unescape(input: &str) -> String {
971 let mut out = String::with_capacity(input.len());
972 let mut chars = input.chars();
973 while let Some(ch) = chars.next() {
974 if ch == '\\' {
975 match chars.next() {
976 Some('"') => out.push('"'),
977 Some('\\') => out.push('\\'),
978 Some('n') => out.push('\n'),
979 Some('r') => out.push('\r'),
980 Some('t') => out.push('\t'),
981 Some('u') => {
982 let hex: String = chars.by_ref().take(4).collect();
983 if let Ok(cp) = u32::from_str_radix(&hex, 16)
984 && let Some(c) = char::from_u32(cp)
985 {
986 out.push(c);
987 }
988 }
989 Some(c) => {
990 out.push('\\');
991 out.push(c);
992 }
993 None => out.push('\\'),
994 }
995 } else {
996 out.push(ch);
997 }
998 }
999 out
1000}
1001
1002fn parse_trace_line(line: &str, line_num: usize) -> Result<TraceRecord, TraceParseError> {
1003 let err = |msg: &str| TraceParseError {
1004 line: line_num,
1005 message: msg.to_string(),
1006 };
1007
1008 let schema_version = extract_str(line, "schema_version")
1009 .ok_or_else(|| err("missing \"schema_version\" field"))?;
1010 if schema_version != SCHEMA_VERSION {
1011 return Err(err(&format!(
1012 "unsupported schema_version: {schema_version}"
1013 )));
1014 }
1015
1016 let event = extract_str(line, "event").ok_or_else(|| err("missing \"event\" field"))?;
1017
1018 match event {
1019 "trace_header" => {
1020 let seed = extract_u64(line, "seed").unwrap_or(0);
1021 let cols = extract_u16(line, "cols").ok_or_else(|| err("missing cols"))?;
1022 let rows = extract_u16(line, "rows").ok_or_else(|| err("missing rows"))?;
1023 let profile = extract_str(line, "profile")
1024 .map(|s| s.to_string())
1025 .unwrap_or_else(|| "modern".to_string());
1026 Ok(TraceRecord::Header {
1027 seed,
1028 cols,
1029 rows,
1030 profile,
1031 })
1032 }
1033 "input" => {
1034 let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
1035 let data = extract_object(line, "data").ok_or_else(|| err("missing data object"))?;
1036 let event = parse_event_json(data).map_err(|e| err(&e))?;
1037 Ok(TraceRecord::Input { ts_ns, event })
1038 }
1039 "resize" => {
1040 let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
1041 let cols = extract_u16(line, "cols").ok_or_else(|| err("missing cols"))?;
1042 let rows = extract_u16(line, "rows").ok_or_else(|| err("missing rows"))?;
1043 Ok(TraceRecord::Resize { ts_ns, cols, rows })
1044 }
1045 "tick" => {
1046 let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
1047 Ok(TraceRecord::Tick { ts_ns })
1048 }
1049 "frame" => {
1050 let frame_idx =
1051 extract_u64(line, "frame_idx").ok_or_else(|| err("missing frame_idx"))?;
1052 let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
1053 let checksum =
1054 extract_hex_u64(line, "frame_hash").ok_or_else(|| err("missing frame_hash"))?;
1055 let checksum_chain = extract_hex_u64(line, "checksum_chain")
1056 .ok_or_else(|| err("missing checksum_chain"))?;
1057 Ok(TraceRecord::Frame {
1058 frame_idx,
1059 ts_ns,
1060 checksum,
1061 checksum_chain,
1062 })
1063 }
1064 "trace_summary" => {
1065 let total_frames =
1066 extract_u64(line, "total_frames").ok_or_else(|| err("missing total_frames"))?;
1067 let final_checksum_chain = extract_hex_u64(line, "final_checksum_chain")
1068 .ok_or_else(|| err("missing final_checksum_chain"))?;
1069 Ok(TraceRecord::Summary {
1070 total_frames,
1071 final_checksum_chain,
1072 })
1073 }
1074 other => Err(err(&format!("unknown event type: {other}"))),
1075 }
1076}
1077
1078fn parse_event_json(data: &str) -> Result<Event, String> {
1079 let kind = extract_str(data, "kind").ok_or("missing event kind")?;
1080 match kind {
1081 "key" => {
1082 let code_str = extract_str(data, "code").ok_or("missing key code")?;
1083 let code = parse_key_code(&json_unescape(code_str))?;
1084 let mods_bits = extract_u64(data, "modifiers")
1085 .or(extract_u64(data, "mods"))
1086 .unwrap_or(0) as u8;
1087 let modifiers = Modifiers::from_bits(mods_bits).unwrap_or_else(Modifiers::empty);
1088 let event_kind = if let Some(event_kind_str) = extract_str(data, "event_kind") {
1089 match event_kind_str {
1090 "press" => KeyEventKind::Press,
1091 "repeat" => KeyEventKind::Repeat,
1092 "release" => KeyEventKind::Release,
1093 _ => KeyEventKind::Press,
1094 }
1095 } else {
1096 let phase = extract_str(data, "phase").unwrap_or("down");
1097 let repeat = extract_bool(data, "repeat").unwrap_or(false);
1098 parse_key_event_kind(phase, repeat)
1099 };
1100 Ok(Event::Key(KeyEvent {
1101 code,
1102 modifiers,
1103 kind: event_kind,
1104 }))
1105 }
1106 "mouse" => {
1107 let mouse_kind = if let Some(mouse_kind_str) = extract_str(data, "mouse_kind") {
1108 parse_mouse_event_kind(mouse_kind_str)?
1109 } else {
1110 let phase = extract_str(data, "phase").ok_or("missing phase for mouse event")?;
1111 let button = extract_u64(data, "button")
1112 .map(|raw| {
1113 u8::try_from(raw).map_err(|_| "mouse button out of range".to_string())
1114 })
1115 .transpose()?;
1116 parse_mouse_phase_and_button(phase, button)?
1117 };
1118 let x = extract_u16(data, "x").unwrap_or(0);
1119 let y = extract_u16(data, "y").unwrap_or(0);
1120 let mods_bits = extract_u64(data, "modifiers")
1121 .or(extract_u64(data, "mods"))
1122 .unwrap_or(0) as u8;
1123 let modifiers = Modifiers::from_bits(mods_bits).unwrap_or_else(Modifiers::empty);
1124 Ok(Event::Mouse(MouseEvent {
1125 kind: mouse_kind,
1126 x,
1127 y,
1128 modifiers,
1129 }))
1130 }
1131 "wheel" => {
1132 let x = extract_u16(data, "x").unwrap_or(0);
1133 let y = extract_u16(data, "y").unwrap_or(0);
1134 let dx = extract_i64(data, "dx")
1135 .and_then(|value| i16::try_from(value).ok())
1136 .unwrap_or(0);
1137 let dy = extract_i64(data, "dy")
1138 .and_then(|value| i16::try_from(value).ok())
1139 .unwrap_or(0);
1140 let kind = if dy < 0 {
1141 MouseEventKind::ScrollUp
1142 } else if dy > 0 {
1143 MouseEventKind::ScrollDown
1144 } else if dx < 0 {
1145 MouseEventKind::ScrollLeft
1146 } else if dx > 0 {
1147 MouseEventKind::ScrollRight
1148 } else {
1149 return Err("wheel event must include non-zero dx or dy".to_string());
1150 };
1151 let mods_bits = extract_u64(data, "modifiers")
1152 .or(extract_u64(data, "mods"))
1153 .unwrap_or(0) as u8;
1154 let modifiers = Modifiers::from_bits(mods_bits).unwrap_or_else(Modifiers::empty);
1155 Ok(Event::Mouse(MouseEvent {
1156 kind,
1157 x,
1158 y,
1159 modifiers,
1160 }))
1161 }
1162 "resize" => {
1163 let width = extract_u16(data, "width").ok_or("missing width")?;
1164 let height = extract_u16(data, "height").ok_or("missing height")?;
1165 Ok(Event::Resize { width, height })
1166 }
1167 "paste" => {
1168 let text = extract_str(data, "text")
1169 .or(extract_str(data, "data"))
1170 .map(json_unescape)
1171 .unwrap_or_default();
1172 let bracketed = extract_bool(data, "bracketed").unwrap_or(true);
1173 Ok(Event::Paste(PasteEvent::new(text, bracketed)))
1174 }
1175 "focus" => {
1176 let gained = extract_bool(data, "gained")
1177 .or(extract_bool(data, "focused"))
1178 .unwrap_or(true);
1179 Ok(Event::Focus(gained))
1180 }
1181 "clipboard" => {
1182 let content = extract_str(data, "content")
1183 .map(json_unescape)
1184 .unwrap_or_default();
1185 let source_str = extract_str(data, "source").unwrap_or("unknown");
1186 let source = match source_str {
1187 "osc52" => ClipboardSource::Osc52,
1188 _ => ClipboardSource::Unknown,
1189 };
1190 Ok(Event::Clipboard(ClipboardEvent::new(content, source)))
1191 }
1192 "tick" => Ok(Event::Tick),
1193 other => Err(format!("unknown event kind: {other}")),
1194 }
1195}
1196
1197fn parse_key_code(s: &str) -> Result<KeyCode, String> {
1198 if let Some(rest) = s.strip_prefix("char:") {
1199 let ch = rest.chars().next().ok_or("empty char code")?;
1200 return Ok(KeyCode::Char(ch));
1201 }
1202 if let Some(rest) = s.strip_prefix("f:") {
1203 let n: u8 = rest.parse().map_err(|_| "invalid F-key number")?;
1204 return Ok(KeyCode::F(n));
1205 }
1206 if let Some(n) = parse_function_key_token(s) {
1207 return Ok(KeyCode::F(n));
1208 }
1209
1210 let mut chars = s.chars();
1211 if let Some(ch) = chars.next()
1212 && chars.next().is_none()
1213 {
1214 return Ok(KeyCode::Char(ch));
1215 }
1216
1217 let normalized = s.to_ascii_lowercase();
1218 match normalized.as_str() {
1219 "enter" | "return" => Ok(KeyCode::Enter),
1220 "escape" | "esc" => Ok(KeyCode::Escape),
1221 "backspace" => Ok(KeyCode::Backspace),
1222 "tab" => Ok(KeyCode::Tab),
1223 "backtab" => Ok(KeyCode::BackTab),
1224 "delete" => Ok(KeyCode::Delete),
1225 "insert" => Ok(KeyCode::Insert),
1226 "home" => Ok(KeyCode::Home),
1227 "end" => Ok(KeyCode::End),
1228 "pageup" => Ok(KeyCode::PageUp),
1229 "pagedown" => Ok(KeyCode::PageDown),
1230 "up" | "arrowup" => Ok(KeyCode::Up),
1231 "down" | "arrowdown" => Ok(KeyCode::Down),
1232 "left" | "arrowleft" => Ok(KeyCode::Left),
1233 "right" | "arrowright" => Ok(KeyCode::Right),
1234 "null" | "unidentified" => Ok(KeyCode::Null),
1235 "media_play_pause" => Ok(KeyCode::MediaPlayPause),
1236 "media_stop" => Ok(KeyCode::MediaStop),
1237 "media_next" => Ok(KeyCode::MediaNextTrack),
1238 "media_prev" => Ok(KeyCode::MediaPrevTrack),
1239 other => Err(format!("unknown key code: {other}")),
1240 }
1241}
1242
1243fn parse_function_key_token(s: &str) -> Option<u8> {
1244 let rest = s.strip_prefix('F').or_else(|| s.strip_prefix('f'))?;
1245 if rest.is_empty() || !rest.chars().all(|ch| ch.is_ascii_digit()) {
1246 return None;
1247 }
1248 rest.parse().ok()
1249}
1250
1251fn parse_key_event_kind(phase: &str, repeat: bool) -> KeyEventKind {
1252 if phase.eq_ignore_ascii_case("up") || phase.eq_ignore_ascii_case("release") {
1253 KeyEventKind::Release
1254 } else if repeat {
1255 KeyEventKind::Repeat
1256 } else {
1257 KeyEventKind::Press
1258 }
1259}
1260
1261fn parse_mouse_event_kind(s: &str) -> Result<MouseEventKind, String> {
1262 match s {
1263 "down_left" => Ok(MouseEventKind::Down(MouseButton::Left)),
1264 "down_right" => Ok(MouseEventKind::Down(MouseButton::Right)),
1265 "down_middle" => Ok(MouseEventKind::Down(MouseButton::Middle)),
1266 "up_left" => Ok(MouseEventKind::Up(MouseButton::Left)),
1267 "up_right" => Ok(MouseEventKind::Up(MouseButton::Right)),
1268 "up_middle" => Ok(MouseEventKind::Up(MouseButton::Middle)),
1269 "drag_left" => Ok(MouseEventKind::Drag(MouseButton::Left)),
1270 "drag_right" => Ok(MouseEventKind::Drag(MouseButton::Right)),
1271 "drag_middle" => Ok(MouseEventKind::Drag(MouseButton::Middle)),
1272 "moved" => Ok(MouseEventKind::Moved),
1273 "scroll_up" => Ok(MouseEventKind::ScrollUp),
1274 "scroll_down" => Ok(MouseEventKind::ScrollDown),
1275 "scroll_left" => Ok(MouseEventKind::ScrollLeft),
1276 "scroll_right" => Ok(MouseEventKind::ScrollRight),
1277 other => Err(format!("unknown mouse event kind: {other}")),
1278 }
1279}
1280
1281fn parse_mouse_phase_and_button(phase: &str, button: Option<u8>) -> Result<MouseEventKind, String> {
1282 match phase {
1283 "down" => Ok(MouseEventKind::Down(parse_mouse_button(
1284 button.ok_or("mouse down requires button")?,
1285 )?)),
1286 "up" => Ok(MouseEventKind::Up(parse_mouse_button(
1287 button.ok_or("mouse up requires button")?,
1288 )?)),
1289 "drag" => Ok(MouseEventKind::Drag(parse_mouse_button(
1290 button.ok_or("mouse drag requires button")?,
1291 )?)),
1292 "move" => Ok(MouseEventKind::Moved),
1293 other => Err(format!("unknown mouse phase: {other}")),
1294 }
1295}
1296
1297fn parse_mouse_button(raw: u8) -> Result<MouseButton, String> {
1298 match raw {
1299 0 => Ok(MouseButton::Left),
1300 1 => Ok(MouseButton::Middle),
1301 2 => Ok(MouseButton::Right),
1302 other => Err(format!("unsupported mouse button: {other}")),
1303 }
1304}
1305
1306pub fn gate_trace<M: ftui_runtime::program::Model>(
1314 model: M,
1315 trace: &SessionTrace,
1316) -> Result<GateReport, ReplayError> {
1317 let result = replay(model, trace)?;
1318
1319 let frame_checksums: Vec<(u64, u64)> = trace
1320 .records
1321 .iter()
1322 .filter_map(|r| match r {
1323 TraceRecord::Frame {
1324 frame_idx,
1325 checksum,
1326 ..
1327 } => Some((*frame_idx, *checksum)),
1328 _ => None,
1329 })
1330 .collect();
1331
1332 let diff = result.first_mismatch.as_ref().map(|m| {
1333 let mut event_idx: u64 = 0;
1335 let mut last_event_desc = String::new();
1336 let mut frame_count: u64 = 0;
1337 for record in &trace.records {
1338 match record {
1339 TraceRecord::Frame { .. } => {
1340 if frame_count == m.frame_idx {
1341 break;
1342 }
1343 frame_count += 1;
1344 }
1345 TraceRecord::Input { event, .. } => {
1346 last_event_desc = format!("{event:?}");
1347 event_idx += 1;
1348 }
1349 TraceRecord::Resize { cols, rows, .. } => {
1350 last_event_desc = format!("Resize({cols}x{rows})");
1351 event_idx += 1;
1352 }
1353 TraceRecord::Tick { ts_ns } => {
1354 last_event_desc = format!("Tick(ts_ns={ts_ns})");
1355 event_idx += 1;
1356 }
1357 _ => {}
1358 }
1359 }
1360
1361 GateDiff {
1362 frame_idx: m.frame_idx,
1363 event_idx,
1364 last_event: last_event_desc,
1365 expected_checksum: m.expected,
1366 actual_checksum: m.actual,
1367 }
1368 });
1369
1370 Ok(GateReport {
1371 passed: result.ok(),
1372 total_frames: result.total_frames,
1373 expected_frames: frame_checksums.len() as u64,
1374 final_checksum_chain: result.final_checksum_chain,
1375 diff,
1376 })
1377}
1378
1379#[derive(Debug, Clone)]
1381pub struct GateReport {
1382 pub passed: bool,
1384 pub total_frames: u64,
1386 pub expected_frames: u64,
1388 pub final_checksum_chain: u64,
1390 pub diff: Option<GateDiff>,
1392}
1393
1394impl GateReport {
1395 pub fn format(&self) -> String {
1397 if self.passed {
1398 format!(
1399 "PASS: {}/{} frames verified, final_chain={:016x}",
1400 self.total_frames, self.expected_frames, self.final_checksum_chain
1401 )
1402 } else if let Some(d) = &self.diff {
1403 format!(
1404 "FAIL at frame {} (after event #{}: {}): expected {:016x}, got {:016x}",
1405 d.frame_idx, d.event_idx, d.last_event, d.expected_checksum, d.actual_checksum
1406 )
1407 } else {
1408 format!(
1409 "FAIL: {}/{} frames, unknown mismatch",
1410 self.total_frames, self.expected_frames
1411 )
1412 }
1413 }
1414}
1415
1416#[derive(Debug, Clone)]
1418pub struct GateDiff {
1419 pub frame_idx: u64,
1421 pub event_idx: u64,
1423 pub last_event: String,
1425 pub expected_checksum: u64,
1427 pub actual_checksum: u64,
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433 use super::*;
1434 use ftui_core::event::{
1435 KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
1436 PasteEvent,
1437 };
1438 use ftui_render::cell::Cell;
1439 use ftui_render::frame::Frame;
1440 use ftui_runtime::program::{Cmd, Model};
1441 use pretty_assertions::assert_eq;
1442
1443 struct Counter {
1446 value: i32,
1447 }
1448
1449 #[derive(Debug)]
1450 enum CounterMsg {
1451 Increment,
1452 Decrement,
1453 Reset,
1454 Quit,
1455 }
1456
1457 impl From<Event> for CounterMsg {
1458 fn from(event: Event) -> Self {
1459 match event {
1460 Event::Key(k) if k.code == KeyCode::Char('+') => CounterMsg::Increment,
1461 Event::Key(k) if k.code == KeyCode::Char('-') => CounterMsg::Decrement,
1462 Event::Key(k) if k.code == KeyCode::Char('r') => CounterMsg::Reset,
1463 Event::Key(k) if k.code == KeyCode::Char('q') => CounterMsg::Quit,
1464 Event::Tick => CounterMsg::Increment,
1465 _ => CounterMsg::Increment,
1466 }
1467 }
1468 }
1469
1470 impl Model for Counter {
1471 type Message = CounterMsg;
1472
1473 fn init(&mut self) -> Cmd<Self::Message> {
1474 Cmd::none()
1475 }
1476
1477 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
1478 match msg {
1479 CounterMsg::Increment => {
1480 self.value += 1;
1481 Cmd::none()
1482 }
1483 CounterMsg::Decrement => {
1484 self.value -= 1;
1485 Cmd::none()
1486 }
1487 CounterMsg::Reset => {
1488 self.value = 0;
1489 Cmd::none()
1490 }
1491 CounterMsg::Quit => Cmd::quit(),
1492 }
1493 }
1494
1495 fn view(&self, frame: &mut Frame) {
1496 let text = format!("Count: {}", self.value);
1497 for (i, c) in text.chars().enumerate() {
1498 if (i as u16) < frame.width() {
1499 frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
1500 }
1501 }
1502 }
1503 }
1504
1505 fn key_event(c: char) -> Event {
1506 Event::Key(KeyEvent {
1507 code: KeyCode::Char(c),
1508 modifiers: Modifiers::empty(),
1509 kind: KeyEventKind::Press,
1510 })
1511 }
1512
1513 fn parse_single_input_event(data_json: &str) -> Event {
1514 let line = format!(
1515 r#"{{"schema_version":"{}","event":"input","ts_ns":0,"data":{}}}"#,
1516 SCHEMA_VERSION, data_json
1517 );
1518 let trace = SessionTrace::from_jsonl(&line).expect("input JSON should parse");
1519 trace
1520 .records
1521 .into_iter()
1522 .next()
1523 .and_then(|record| match record {
1524 TraceRecord::Input { event, .. } => Some(event),
1525 _ => None,
1526 })
1527 .expect("expected single input record")
1528 }
1529
1530 fn new_counter(value: i32) -> Counter {
1531 Counter { value }
1532 }
1533
1534 #[test]
1537 fn fnv1a64_pair_is_deterministic() {
1538 let a = fnv1a64_pair(0, 1234);
1539 let b = fnv1a64_pair(0, 1234);
1540 assert_eq!(a, b);
1541 }
1542
1543 #[test]
1544 fn fnv1a64_pair_differs_for_different_input() {
1545 assert_ne!(fnv1a64_pair(0, 1), fnv1a64_pair(0, 2));
1546 assert_ne!(fnv1a64_pair(1, 0), fnv1a64_pair(2, 0));
1547 }
1548
1549 #[test]
1552 fn recorder_produces_header_and_summary() {
1553 let mut rec = SessionRecorder::new(new_counter(0), 80, 24, 42);
1554 rec.init().unwrap();
1555
1556 let trace = rec.finish();
1557 assert!(trace.records.len() >= 3); assert!(matches!(
1561 &trace.records[0],
1562 TraceRecord::Header {
1563 seed: 42,
1564 cols: 80,
1565 rows: 24,
1566 ..
1567 }
1568 ));
1569
1570 assert!(matches!(
1572 trace.records.last().unwrap(),
1573 TraceRecord::Summary {
1574 total_frames: 1,
1575 ..
1576 }
1577 ));
1578 }
1579
1580 #[test]
1581 fn recorder_captures_init_frame() {
1582 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1583 rec.init().unwrap();
1584
1585 let trace = rec.finish();
1586 let frames: Vec<_> = trace
1587 .records
1588 .iter()
1589 .filter(|r| matches!(r, TraceRecord::Frame { .. }))
1590 .collect();
1591 assert_eq!(frames.len(), 1);
1592
1593 if let TraceRecord::Frame {
1594 frame_idx,
1595 checksum,
1596 ..
1597 } = &frames[0]
1598 {
1599 assert_eq!(*frame_idx, 0);
1600 assert_ne!(*checksum, 0); }
1602 }
1603
1604 #[test]
1607 fn record_replay_identical_checksums() {
1608 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1610 rec.init().unwrap();
1611
1612 rec.push_event(1_000_000, key_event('+'));
1613 rec.push_event(2_000_000, key_event('+'));
1614 rec.push_event(3_000_000, key_event('-'));
1615 rec.step().unwrap();
1616
1617 rec.push_event(16_000_000, key_event('+'));
1618 rec.step().unwrap();
1619
1620 let trace = rec.finish();
1621 assert_eq!(trace.frame_count(), 3); let result = replay(new_counter(0), &trace).unwrap();
1625 assert!(result.ok(), "replay mismatch: {:?}", result.first_mismatch);
1626 assert_eq!(result.total_frames, 3);
1627 assert_eq!(
1628 result.final_checksum_chain,
1629 trace.final_checksum_chain().unwrap()
1630 );
1631 }
1632
1633 #[test]
1634 fn replay_detects_different_initial_state() {
1635 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1637 rec.init().unwrap();
1638 let trace = rec.finish();
1639
1640 let result = replay(new_counter(5), &trace).unwrap();
1642 assert!(!result.ok());
1643 assert_eq!(result.first_mismatch.as_ref().unwrap().frame_idx, 0);
1644 }
1645
1646 #[test]
1647 fn replay_detects_divergence_after_events() {
1648 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1650 rec.init().unwrap();
1651
1652 rec.push_event(1_000_000, key_event('+'));
1653 rec.push_event(2_000_000, key_event('+'));
1654 rec.step().unwrap();
1655
1656 let trace = rec.finish();
1657
1658 let result = replay(new_counter(1), &trace).unwrap();
1660 assert!(!result.ok());
1661 }
1662
1663 #[test]
1666 fn resize_is_recorded_and_replayed() {
1667 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1668 rec.init().unwrap();
1669
1670 rec.resize(5_000_000, 40, 2);
1671 rec.step().unwrap();
1672
1673 let trace = rec.finish();
1674
1675 assert!(trace.records.iter().any(|r| matches!(
1677 r,
1678 TraceRecord::Resize {
1679 cols: 40,
1680 rows: 2,
1681 ..
1682 }
1683 )));
1684
1685 let result = replay(new_counter(0), &trace).unwrap();
1687 assert!(
1688 result.ok(),
1689 "resize replay mismatch: {:?}",
1690 result.first_mismatch
1691 );
1692 }
1693
1694 #[test]
1697 fn multi_step_record_replay() {
1698 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1699 rec.init().unwrap();
1700
1701 for i in 0..5 {
1702 rec.push_event(i * 16_000_000, key_event('+'));
1703 rec.step().unwrap();
1704 }
1705
1706 let trace = rec.finish();
1707 assert_eq!(trace.frame_count(), 6); let result = replay(new_counter(0), &trace).unwrap();
1710 assert!(
1711 result.ok(),
1712 "multi-step mismatch: {:?}",
1713 result.first_mismatch
1714 );
1715 assert_eq!(result.total_frames, 6);
1716 }
1717
1718 #[test]
1721 fn quit_stops_recording() {
1722 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1723 rec.init().unwrap();
1724
1725 rec.push_event(1_000_000, key_event('+'));
1726 rec.push_event(2_000_000, key_event('q'));
1727 let result = rec.step().unwrap();
1728 assert!(!result.running);
1729
1730 let trace = rec.finish();
1731 assert_eq!(trace.frame_count(), 1);
1733 }
1734
1735 #[test]
1738 fn empty_session_replay() {
1739 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1740 rec.init().unwrap();
1741 let trace = rec.finish();
1742
1743 let result = replay(new_counter(0), &trace).unwrap();
1744 assert!(result.ok());
1745 assert_eq!(result.total_frames, 1); }
1747
1748 #[test]
1751 fn session_trace_frame_count() {
1752 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1753 rec.init().unwrap();
1754 rec.push_event(1_000_000, key_event('+'));
1755 rec.step().unwrap();
1756 let trace = rec.finish();
1757 assert_eq!(trace.frame_count(), 2);
1758 }
1759
1760 #[test]
1761 fn session_trace_final_checksum_chain() {
1762 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1763 rec.init().unwrap();
1764 let trace = rec.finish();
1765 assert!(trace.final_checksum_chain().is_some());
1766 assert_ne!(trace.final_checksum_chain().unwrap(), 0);
1767 }
1768
1769 #[test]
1772 fn replay_missing_header_returns_error() {
1773 let trace = SessionTrace { records: vec![] };
1774 let result = replay(new_counter(0), &trace);
1775 assert!(matches!(result, Err(ReplayError::MissingHeader)));
1776 }
1777
1778 #[test]
1779 fn replay_non_header_first_returns_error() {
1780 let trace = SessionTrace {
1781 records: vec![TraceRecord::Tick { ts_ns: 0 }],
1782 };
1783 let result = replay(new_counter(0), &trace);
1784 assert!(matches!(result, Err(ReplayError::MissingHeader)));
1785 }
1786
1787 #[test]
1788 fn trace_validate_missing_summary_returns_typed_error() {
1789 let trace = SessionTrace {
1790 records: vec![TraceRecord::Header {
1791 seed: 0,
1792 cols: 80,
1793 rows: 24,
1794 profile: "modern".to_string(),
1795 }],
1796 };
1797 let result = trace.validate();
1798 assert_eq!(result, Err(TraceValidationError::MissingSummary));
1799 }
1800
1801 #[test]
1802 fn trace_validate_summary_frame_count_mismatch_returns_typed_error() {
1803 let trace = SessionTrace {
1804 records: vec![
1805 TraceRecord::Header {
1806 seed: 0,
1807 cols: 80,
1808 rows: 24,
1809 profile: "modern".to_string(),
1810 },
1811 TraceRecord::Frame {
1812 frame_idx: 0,
1813 ts_ns: 0,
1814 checksum: 0x1,
1815 checksum_chain: 0x10,
1816 },
1817 TraceRecord::Summary {
1818 total_frames: 2,
1819 final_checksum_chain: 0x10,
1820 },
1821 ],
1822 };
1823 let result = trace.validate();
1824 assert_eq!(
1825 result,
1826 Err(TraceValidationError::SummaryFrameCountMismatch {
1827 expected: 1,
1828 actual: 2,
1829 })
1830 );
1831 }
1832
1833 #[test]
1834 fn trace_validate_frame_index_gap_returns_typed_error() {
1835 let trace = SessionTrace {
1836 records: vec![
1837 TraceRecord::Header {
1838 seed: 0,
1839 cols: 80,
1840 rows: 24,
1841 profile: "modern".to_string(),
1842 },
1843 TraceRecord::Frame {
1844 frame_idx: 1,
1845 ts_ns: 0,
1846 checksum: 0x1,
1847 checksum_chain: 0x10,
1848 },
1849 TraceRecord::Summary {
1850 total_frames: 1,
1851 final_checksum_chain: 0x10,
1852 },
1853 ],
1854 };
1855 let result = trace.validate();
1856 assert_eq!(
1857 result,
1858 Err(TraceValidationError::FrameIndexMismatch {
1859 expected: 0,
1860 actual: 1,
1861 })
1862 );
1863 }
1864
1865 #[test]
1866 fn trace_validate_timestamp_regression_returns_typed_error() {
1867 let trace = SessionTrace {
1868 records: vec![
1869 TraceRecord::Header {
1870 seed: 0,
1871 cols: 80,
1872 rows: 24,
1873 profile: "modern".to_string(),
1874 },
1875 TraceRecord::Tick { ts_ns: 20 },
1876 TraceRecord::Tick { ts_ns: 10 },
1877 TraceRecord::Summary {
1878 total_frames: 0,
1879 final_checksum_chain: 0,
1880 },
1881 ],
1882 };
1883 let result = trace.validate();
1884 assert_eq!(
1885 result,
1886 Err(TraceValidationError::TimestampRegression {
1887 previous: 20,
1888 current: 10,
1889 record_index: 2,
1890 })
1891 );
1892 }
1893
1894 #[test]
1895 fn replay_validates_trace_before_execution() {
1896 let trace = SessionTrace {
1897 records: vec![TraceRecord::Header {
1898 seed: 0,
1899 cols: 80,
1900 rows: 24,
1901 profile: "modern".to_string(),
1902 }],
1903 };
1904 let result = replay(new_counter(0), &trace);
1905 assert_eq!(
1906 result,
1907 Err(ReplayError::InvalidTrace(
1908 TraceValidationError::MissingSummary
1909 ))
1910 );
1911 }
1912
1913 #[test]
1916 fn same_inputs_produce_same_trace_checksums() {
1917 fn record_session() -> SessionTrace {
1918 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1919 rec.init().unwrap();
1920
1921 rec.push_event(1_000_000, key_event('+'));
1922 rec.push_event(2_000_000, key_event('+'));
1923 rec.push_event(3_000_000, key_event('-'));
1924 rec.step().unwrap();
1925
1926 rec.push_event(16_000_000, key_event('+'));
1927 rec.step().unwrap();
1928
1929 rec.finish()
1930 }
1931
1932 let t1 = record_session();
1933 let t2 = record_session();
1934 let t3 = record_session();
1935
1936 let checksums = |t: &SessionTrace| -> Vec<u64> {
1938 t.records
1939 .iter()
1940 .filter_map(|r| match r {
1941 TraceRecord::Frame { checksum, .. } => Some(*checksum),
1942 _ => None,
1943 })
1944 .collect()
1945 };
1946
1947 assert_eq!(checksums(&t1), checksums(&t2));
1948 assert_eq!(checksums(&t2), checksums(&t3));
1949 assert_eq!(t1.final_checksum_chain(), t2.final_checksum_chain());
1950 }
1951
1952 #[test]
1955 fn mouse_event_record_replay() {
1956 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1957 rec.init().unwrap();
1958
1959 let mouse = Event::Mouse(MouseEvent {
1960 kind: MouseEventKind::Down(MouseButton::Left),
1961 x: 5,
1962 y: 0,
1963 modifiers: Modifiers::empty(),
1964 });
1965 rec.push_event(1_000_000, mouse);
1966 rec.step().unwrap();
1967
1968 let trace = rec.finish();
1969 let result = replay(new_counter(0), &trace).unwrap();
1970 assert!(result.ok());
1971 }
1972
1973 #[test]
1974 fn paste_event_record_replay() {
1975 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1976 rec.init().unwrap();
1977
1978 let paste = Event::Paste(PasteEvent::bracketed("hello"));
1979 rec.push_event(1_000_000, paste);
1980 rec.step().unwrap();
1981
1982 let trace = rec.finish();
1983 let result = replay(new_counter(0), &trace).unwrap();
1984 assert!(result.ok());
1985 }
1986
1987 #[test]
1988 fn focus_event_record_replay() {
1989 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1990 rec.init().unwrap();
1991
1992 rec.push_event(1_000_000, Event::Focus(true));
1993 rec.push_event(2_000_000, Event::Focus(false));
1994 rec.step().unwrap();
1995
1996 let trace = rec.finish();
1997 let result = replay(new_counter(0), &trace).unwrap();
1998 assert!(result.ok());
1999 }
2000
2001 #[test]
2004 fn checksum_chain_is_cumulative() {
2005 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2006 rec.init().unwrap();
2007
2008 rec.push_event(1_000_000, key_event('+'));
2009 rec.step().unwrap();
2010
2011 rec.push_event(2_000_000, key_event('+'));
2012 rec.step().unwrap();
2013
2014 let trace = rec.finish();
2015 let frame_records: Vec<_> = trace
2016 .records
2017 .iter()
2018 .filter_map(|r| match r {
2019 TraceRecord::Frame {
2020 checksum,
2021 checksum_chain,
2022 ..
2023 } => Some((*checksum, *checksum_chain)),
2024 _ => None,
2025 })
2026 .collect();
2027
2028 assert_eq!(frame_records.len(), 3);
2029
2030 let (c0, chain0) = frame_records[0];
2032 assert_eq!(chain0, fnv1a64_pair(0, c0));
2033
2034 let (c1, chain1) = frame_records[1];
2035 assert_eq!(chain1, fnv1a64_pair(chain0, c1));
2036
2037 let (c2, chain2) = frame_records[2];
2038 assert_eq!(chain2, fnv1a64_pair(chain1, c2));
2039
2040 assert_eq!(trace.final_checksum_chain(), Some(chain2));
2042 }
2043
2044 #[test]
2047 fn recorder_exposes_program() {
2048 let mut rec = SessionRecorder::new(new_counter(42), 20, 1, 0);
2049 rec.init().unwrap();
2050 assert_eq!(rec.program().model().value, 42);
2051 }
2052
2053 #[test]
2056 fn replay_result_ok_when_no_mismatch() {
2057 let r = ReplayResult {
2058 total_frames: 5,
2059 final_checksum_chain: 123,
2060 first_mismatch: None,
2061 };
2062 assert!(r.ok());
2063 }
2064
2065 #[test]
2066 fn replay_result_not_ok_when_mismatch() {
2067 let r = ReplayResult {
2068 total_frames: 5,
2069 final_checksum_chain: 123,
2070 first_mismatch: Some(ReplayMismatch {
2071 frame_idx: 2,
2072 expected: 100,
2073 actual: 200,
2074 }),
2075 };
2076 assert!(!r.ok());
2077 }
2078
2079 #[test]
2080 fn replay_error_display() {
2081 assert_eq!(
2082 ReplayError::MissingHeader.to_string(),
2083 "trace missing header record"
2084 );
2085 let invalid = ReplayError::InvalidTrace(TraceValidationError::MissingSummary);
2086 assert_eq!(
2087 invalid.to_string(),
2088 "invalid trace: trace is missing summary"
2089 );
2090 let be = ReplayError::Backend(WebBackendError::Unsupported("test"));
2091 assert!(be.to_string().contains("test"));
2092 }
2093
2094 #[test]
2097 fn trace_record_header_to_jsonl() {
2098 let r = TraceRecord::Header {
2099 seed: 42,
2100 cols: 80,
2101 rows: 24,
2102 profile: "modern".to_string(),
2103 };
2104 let line = r.to_jsonl();
2105 assert!(line.contains("\"event\":\"trace_header\""));
2106 assert!(line.contains("\"schema_version\":\"golden-trace-v1\""));
2107 assert!(line.contains("\"seed\":42"));
2108 assert!(line.contains("\"cols\":80"));
2109 assert!(line.contains("\"rows\":24"));
2110 assert!(line.contains("\"profile\":\"modern\""));
2111 }
2112
2113 #[test]
2114 fn trace_record_input_key_to_jsonl() {
2115 let r = TraceRecord::Input {
2116 ts_ns: 1_000_000,
2117 event: key_event('+'),
2118 };
2119 let line = r.to_jsonl();
2120 assert!(line.contains("\"event\":\"input\""));
2121 assert!(line.contains("\"ts_ns\":1000000"));
2122 assert!(line.contains("\"kind\":\"key\""));
2123 assert!(line.contains("\"code\":\"char:+\""));
2124 }
2125
2126 #[test]
2127 fn trace_record_resize_to_jsonl() {
2128 let r = TraceRecord::Resize {
2129 ts_ns: 5_000_000,
2130 cols: 120,
2131 rows: 40,
2132 };
2133 let line = r.to_jsonl();
2134 assert!(line.contains("\"event\":\"resize\""));
2135 assert!(line.contains("\"cols\":120"));
2136 assert!(line.contains("\"rows\":40"));
2137 }
2138
2139 #[test]
2140 fn trace_record_frame_to_jsonl() {
2141 let r = TraceRecord::Frame {
2142 frame_idx: 3,
2143 ts_ns: 48_000_000,
2144 checksum: 0xDEADBEEF,
2145 checksum_chain: 0xCAFEBABE,
2146 };
2147 let line = r.to_jsonl();
2148 assert!(line.contains("\"event\":\"frame\""));
2149 assert!(line.contains("\"frame_idx\":3"));
2150 assert!(line.contains("\"frame_hash\":\"00000000deadbeef\""));
2151 assert!(line.contains("\"checksum_chain\":\"00000000cafebabe\""));
2152 }
2153
2154 #[test]
2155 fn trace_record_summary_to_jsonl() {
2156 let r = TraceRecord::Summary {
2157 total_frames: 10,
2158 final_checksum_chain: 0x1234567890ABCDEF,
2159 };
2160 let line = r.to_jsonl();
2161 assert!(line.contains("\"event\":\"trace_summary\""));
2162 assert!(line.contains("\"total_frames\":10"));
2163 assert!(line.contains("\"final_checksum_chain\":\"1234567890abcdef\""));
2164 }
2165
2166 #[test]
2169 fn jsonl_round_trip_full_session() {
2170 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 42);
2172 rec.init().unwrap();
2173 rec.push_event(1_000_000, key_event('+'));
2174 rec.push_event(2_000_000, key_event('+'));
2175 rec.step().unwrap();
2176 let trace = rec.finish();
2177
2178 let jsonl = trace.to_jsonl();
2180 assert!(!jsonl.is_empty());
2181
2182 let parsed = SessionTrace::from_jsonl(&jsonl).unwrap();
2184 assert_eq!(parsed.records.len(), trace.records.len());
2185 assert_eq!(parsed.frame_count(), trace.frame_count());
2186 assert_eq!(parsed.final_checksum_chain(), trace.final_checksum_chain());
2187 }
2188
2189 #[test]
2190 fn jsonl_round_trip_preserves_events() {
2191 let events = vec![
2192 key_event('+'),
2193 key_event('-'),
2194 Event::Key(KeyEvent {
2195 code: KeyCode::Enter,
2196 modifiers: Modifiers::CTRL | Modifiers::SHIFT,
2197 kind: KeyEventKind::Press,
2198 }),
2199 Event::Key(KeyEvent {
2200 code: KeyCode::F(12),
2201 modifiers: Modifiers::ALT,
2202 kind: KeyEventKind::Repeat,
2203 }),
2204 Event::Mouse(MouseEvent {
2205 kind: MouseEventKind::Down(MouseButton::Left),
2206 x: 10,
2207 y: 5,
2208 modifiers: Modifiers::empty(),
2209 }),
2210 Event::Mouse(MouseEvent {
2211 kind: MouseEventKind::ScrollDown,
2212 x: 0,
2213 y: 0,
2214 modifiers: Modifiers::CTRL,
2215 }),
2216 Event::Paste(PasteEvent::bracketed("hello world")),
2217 Event::Focus(true),
2218 Event::Focus(false),
2219 Event::Tick,
2220 ];
2221
2222 for (i, event) in events.iter().enumerate() {
2223 let record = TraceRecord::Input {
2224 ts_ns: i as u64 * 1_000_000,
2225 event: event.clone(),
2226 };
2227 let jsonl = record.to_jsonl();
2228 let parsed = SessionTrace::from_jsonl(&jsonl).unwrap();
2229 let parsed_record = &parsed.records[0];
2230 let TraceRecord::Input {
2231 event: parsed_event,
2232 ..
2233 } = parsed_record
2234 else {
2235 unreachable!("expected Input record for event {i}");
2236 };
2237
2238 assert_eq!(parsed_event, event, "event {i} round-trip failed: {jsonl}");
2239 }
2240 }
2241
2242 #[test]
2243 fn jsonl_round_trip_with_resize() {
2244 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2245 rec.init().unwrap();
2246 rec.resize(5_000_000, 40, 2);
2247 rec.step().unwrap();
2248 let trace = rec.finish();
2249
2250 let jsonl = trace.to_jsonl();
2251 let parsed = SessionTrace::from_jsonl(&jsonl).unwrap();
2252
2253 let result = replay(new_counter(0), &parsed).unwrap();
2255 assert!(
2256 result.ok(),
2257 "parsed trace replay failed: {:?}",
2258 result.first_mismatch
2259 );
2260 }
2261
2262 #[test]
2265 fn from_jsonl_empty_is_ok() {
2266 let trace = SessionTrace::from_jsonl("").unwrap();
2267 assert!(trace.records.is_empty());
2268 }
2269
2270 #[test]
2271 fn from_jsonl_unknown_event_fails() {
2272 let line = r#"{"schema_version":"golden-trace-v1","event":"unknown_type","ts_ns":0}"#;
2273 let result = SessionTrace::from_jsonl(line);
2274 assert!(result.is_err());
2275 assert!(result.unwrap_err().message.contains("unknown event type"));
2276 }
2277
2278 #[test]
2279 fn from_jsonl_missing_event_field_fails() {
2280 let line = r#"{"schema_version":"golden-trace-v1","ts_ns":0}"#;
2281 let result = SessionTrace::from_jsonl(line);
2282 assert!(result.is_err());
2283 }
2284
2285 #[test]
2286 fn from_jsonl_missing_schema_version_fails() {
2287 let line = r#"{"event":"tick","ts_ns":0}"#;
2288 let result = SessionTrace::from_jsonl(line);
2289 assert!(result.is_err());
2290 assert!(result.unwrap_err().message.contains("schema_version"));
2291 }
2292
2293 #[test]
2294 fn from_jsonl_unknown_schema_version_fails() {
2295 let line = r#"{"schema_version":"golden-trace-v2","event":"tick","ts_ns":0}"#;
2296 let result = SessionTrace::from_jsonl(line);
2297 assert!(result.is_err());
2298 assert!(
2299 result
2300 .unwrap_err()
2301 .message
2302 .contains("unsupported schema_version")
2303 );
2304 }
2305
2306 #[test]
2307 fn from_jsonl_validated_surfaces_validation_error_type() {
2308 let jsonl = TraceRecord::Header {
2309 seed: 0,
2310 cols: 80,
2311 rows: 24,
2312 profile: "modern".to_string(),
2313 }
2314 .to_jsonl();
2315 let result = SessionTrace::from_jsonl_validated(&jsonl);
2316 assert!(matches!(
2317 result,
2318 Err(TraceLoadError::Validation(
2319 TraceValidationError::MissingSummary
2320 ))
2321 ));
2322 }
2323
2324 #[test]
2327 fn json_escape_round_trip() {
2328 let cases = [
2329 "hello",
2330 "with\"quotes",
2331 "back\\slash",
2332 "line\nbreak",
2333 "tab\there",
2334 ];
2335 for input in cases {
2336 let escaped = json_escape(input);
2337 let unescaped = json_unescape(&escaped);
2338 assert_eq!(unescaped, input, "round-trip failed for: {input:?}");
2339 }
2340 }
2341
2342 #[test]
2343 fn extract_str_basic() {
2344 let json = r#"{"name":"alice","age":30}"#;
2345 assert_eq!(extract_str(json, "name"), Some("alice"));
2346 }
2347
2348 #[test]
2349 fn extract_u64_basic() {
2350 let json = r#"{"count":42,"name":"test"}"#;
2351 assert_eq!(extract_u64(json, "count"), Some(42));
2352 }
2353
2354 #[test]
2355 fn extract_i64_basic() {
2356 let json = r#"{"dx":-12,"dy":7}"#;
2357 assert_eq!(extract_i64(json, "dx"), Some(-12));
2358 assert_eq!(extract_i64(json, "dy"), Some(7));
2359 }
2360
2361 #[test]
2362 fn extract_bool_basic() {
2363 let json = r#"{"enabled":true,"disabled":false}"#;
2364 assert_eq!(extract_bool(json, "enabled"), Some(true));
2365 assert_eq!(extract_bool(json, "disabled"), Some(false));
2366 }
2367
2368 #[test]
2369 fn extract_hex_u64_basic() {
2370 let json = r#"{"hash":"00000000deadbeef"}"#;
2371 assert_eq!(extract_hex_u64(json, "hash"), Some(0xDEADBEEF));
2372 }
2373
2374 #[test]
2375 fn from_jsonl_parses_frankenterm_key_schema() {
2376 let down = parse_single_input_event(
2377 r#"{"kind":"key","phase":"down","code":"F12","mods":5,"repeat":false}"#,
2378 );
2379 assert_eq!(
2380 down,
2381 Event::Key(KeyEvent {
2382 code: KeyCode::F(12),
2383 modifiers: Modifiers::SHIFT | Modifiers::CTRL,
2384 kind: KeyEventKind::Press,
2385 })
2386 );
2387
2388 let repeat = parse_single_input_event(
2389 r#"{"kind":"key","phase":"down","code":"a","mods":0,"repeat":true}"#,
2390 );
2391 assert_eq!(
2392 repeat,
2393 Event::Key(KeyEvent {
2394 code: KeyCode::Char('a'),
2395 modifiers: Modifiers::empty(),
2396 kind: KeyEventKind::Repeat,
2397 })
2398 );
2399
2400 let release = parse_single_input_event(
2401 r#"{"kind":"key","phase":"up","code":"Enter","mods":0,"repeat":false}"#,
2402 );
2403 assert_eq!(
2404 release,
2405 Event::Key(KeyEvent {
2406 code: KeyCode::Enter,
2407 modifiers: Modifiers::empty(),
2408 kind: KeyEventKind::Release,
2409 })
2410 );
2411 }
2412
2413 #[test]
2414 fn key_event_json_round_trip_unescapes_code() {
2415 let quote_key = Event::Key(KeyEvent {
2416 code: KeyCode::Char('"'),
2417 modifiers: Modifiers::empty(),
2418 kind: KeyEventKind::Press,
2419 });
2420 let quote_json = event_to_json("e_key);
2421 let parsed_quote = parse_event_json("e_json).expect("quote key should parse");
2422 assert_eq!(parsed_quote, quote_key);
2423
2424 let slash_key = Event::Key(KeyEvent {
2425 code: KeyCode::Char('\\'),
2426 modifiers: Modifiers::SHIFT,
2427 kind: KeyEventKind::Press,
2428 });
2429 let slash_json = event_to_json(&slash_key);
2430 let parsed_slash = parse_event_json(&slash_json).expect("slash key should parse");
2431 assert_eq!(parsed_slash, slash_key);
2432 }
2433
2434 #[test]
2435 fn from_jsonl_parses_frankenterm_mouse_and_wheel_schema() {
2436 let mouse = parse_single_input_event(
2437 r#"{"kind":"mouse","phase":"drag","button":2,"x":7,"y":9,"mods":3}"#,
2438 );
2439 assert_eq!(
2440 mouse,
2441 Event::Mouse(MouseEvent {
2442 kind: MouseEventKind::Drag(MouseButton::Right),
2443 x: 7,
2444 y: 9,
2445 modifiers: Modifiers::SHIFT | Modifiers::ALT,
2446 })
2447 );
2448
2449 let wheel =
2450 parse_single_input_event(r#"{"kind":"wheel","x":4,"y":6,"dx":0,"dy":-2,"mods":4}"#);
2451 assert_eq!(
2452 wheel,
2453 Event::Mouse(MouseEvent {
2454 kind: MouseEventKind::ScrollUp,
2455 x: 4,
2456 y: 6,
2457 modifiers: Modifiers::CTRL,
2458 })
2459 );
2460 }
2461
2462 #[test]
2463 fn from_jsonl_parses_frankenterm_paste_and_focus_aliases() {
2464 let paste = parse_single_input_event(r#"{"kind":"paste","data":"hello\nworld"}"#);
2465 assert_eq!(paste, Event::Paste(PasteEvent::new("hello\nworld", true)));
2466
2467 let focus = parse_single_input_event(r#"{"kind":"focus","focused":false}"#);
2468 assert_eq!(focus, Event::Focus(false));
2469 }
2470
2471 #[test]
2474 fn gate_trace_passes_on_correct_replay() {
2475 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2476 rec.init().unwrap();
2477 rec.push_event(1_000_000, key_event('+'));
2478 rec.step().unwrap();
2479 let trace = rec.finish();
2480
2481 let report = gate_trace(new_counter(0), &trace).unwrap();
2482 assert!(report.passed);
2483 assert_eq!(report.total_frames, 2);
2484 assert!(report.diff.is_none());
2485 assert!(report.format().starts_with("PASS"));
2486 }
2487
2488 #[test]
2489 fn gate_trace_fails_with_actionable_diff() {
2490 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2491 rec.init().unwrap();
2492 rec.push_event(1_000_000, key_event('+'));
2493 rec.push_event(2_000_000, key_event('+'));
2494 rec.step().unwrap();
2495 let trace = rec.finish();
2496
2497 let report = gate_trace(new_counter(5), &trace).unwrap();
2499 assert!(!report.passed);
2500 assert!(report.diff.is_some());
2501
2502 let diff = report.diff.as_ref().unwrap();
2503 assert_eq!(diff.frame_idx, 0); let formatted = report.format();
2506 assert!(formatted.starts_with("FAIL"));
2507 assert!(formatted.contains("frame 0"));
2508 }
2509
2510 #[test]
2511 fn gate_trace_diff_has_event_context() {
2512 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2513 rec.init().unwrap();
2514 rec.push_event(1_000_000, key_event('+'));
2515 rec.push_event(2_000_000, key_event('+'));
2516 rec.step().unwrap();
2517 rec.push_event(3_000_000, key_event('-'));
2518 rec.step().unwrap();
2519 let trace = rec.finish();
2520
2521 let mut tampered = trace.clone();
2523 for record in &mut tampered.records {
2524 if let TraceRecord::Frame {
2525 frame_idx,
2526 checksum,
2527 ..
2528 } = record
2529 && *frame_idx == 2
2530 {
2531 *checksum = 0xBAD;
2532 }
2533 }
2534
2535 let report = gate_trace(new_counter(0), &tampered).unwrap();
2536 assert!(!report.passed);
2537 let diff = report.diff.unwrap();
2538 assert_eq!(diff.frame_idx, 2);
2539 assert!(diff.event_idx > 0); }
2541
2542 #[test]
2545 fn jsonl_serialize_parse_replay_round_trip() {
2546 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2548 rec.init().unwrap();
2549
2550 for i in 0..3 {
2551 rec.push_event(i * 16_000_000, key_event('+'));
2552 rec.step().unwrap();
2553 }
2554 let original_trace = rec.finish();
2555
2556 let jsonl = original_trace.to_jsonl();
2558
2559 let parsed_trace = SessionTrace::from_jsonl(&jsonl).unwrap();
2561
2562 let result = replay(new_counter(0), &parsed_trace).unwrap();
2564 assert!(
2565 result.ok(),
2566 "JSONL round-trip replay failed: {:?}",
2567 result.first_mismatch
2568 );
2569 assert_eq!(result.total_frames, original_trace.frame_count());
2570 assert_eq!(
2571 result.final_checksum_chain,
2572 original_trace.final_checksum_chain().unwrap()
2573 );
2574 }
2575
2576 #[test]
2579 fn trace_parse_error_display() {
2580 let e = TraceParseError {
2581 line: 5,
2582 message: "bad field".to_string(),
2583 };
2584 assert_eq!(e.to_string(), "line 5: bad field");
2585 }
2586}