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