1#![forbid(unsafe_code)]
2
3use core::time::Duration;
48
49use ftui_core::event::{
50 ClipboardEvent, ClipboardSource, Event, ImeEvent, ImePhase, KeyCode, KeyEvent, KeyEventKind,
51 Modifiers, 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::Ime(ime) => {
596 format!(
597 r#"{{"kind":"ime","phase":"{}","text":"{}"}}"#,
598 ime_phase_to_str(ime.phase),
599 json_escape(&ime.text)
600 )
601 }
602 Event::Focus(gained) => {
603 format!(r#"{{"kind":"focus","gained":{}}}"#, gained)
604 }
605 Event::Clipboard(c) => {
606 let source = clipboard_source_to_str(c.source);
607 format!(
608 r#"{{"kind":"clipboard","content":"{}","source":"{}"}}"#,
609 json_escape(&c.content),
610 source
611 )
612 }
613 Event::Tick => r#"{"kind":"tick"}"#.to_string(),
614 }
615}
616
617fn key_code_to_str(code: KeyCode) -> String {
618 match code {
619 KeyCode::Char(c) => format!("char:{c}"),
620 KeyCode::Enter => "enter".to_string(),
621 KeyCode::Escape => "escape".to_string(),
622 KeyCode::Backspace => "backspace".to_string(),
623 KeyCode::Tab => "tab".to_string(),
624 KeyCode::BackTab => "backtab".to_string(),
625 KeyCode::Delete => "delete".to_string(),
626 KeyCode::Insert => "insert".to_string(),
627 KeyCode::Home => "home".to_string(),
628 KeyCode::End => "end".to_string(),
629 KeyCode::PageUp => "pageup".to_string(),
630 KeyCode::PageDown => "pagedown".to_string(),
631 KeyCode::Up => "up".to_string(),
632 KeyCode::Down => "down".to_string(),
633 KeyCode::Left => "left".to_string(),
634 KeyCode::Right => "right".to_string(),
635 KeyCode::F(n) => format!("f:{n}"),
636 KeyCode::Null => "null".to_string(),
637 KeyCode::MediaPlayPause => "media_play_pause".to_string(),
638 KeyCode::MediaStop => "media_stop".to_string(),
639 KeyCode::MediaNextTrack => "media_next".to_string(),
640 KeyCode::MediaPrevTrack => "media_prev".to_string(),
641 }
642}
643
644fn key_event_kind_to_str(kind: KeyEventKind) -> &'static str {
645 match kind {
646 KeyEventKind::Press => "press",
647 KeyEventKind::Repeat => "repeat",
648 KeyEventKind::Release => "release",
649 }
650}
651
652fn mouse_event_kind_to_str(kind: MouseEventKind) -> &'static str {
653 match kind {
654 MouseEventKind::Down(MouseButton::Left) => "down_left",
655 MouseEventKind::Down(MouseButton::Right) => "down_right",
656 MouseEventKind::Down(MouseButton::Middle) => "down_middle",
657 MouseEventKind::Up(MouseButton::Left) => "up_left",
658 MouseEventKind::Up(MouseButton::Right) => "up_right",
659 MouseEventKind::Up(MouseButton::Middle) => "up_middle",
660 MouseEventKind::Drag(MouseButton::Left) => "drag_left",
661 MouseEventKind::Drag(MouseButton::Right) => "drag_right",
662 MouseEventKind::Drag(MouseButton::Middle) => "drag_middle",
663 MouseEventKind::Moved => "moved",
664 MouseEventKind::ScrollUp => "scroll_up",
665 MouseEventKind::ScrollDown => "scroll_down",
666 MouseEventKind::ScrollLeft => "scroll_left",
667 MouseEventKind::ScrollRight => "scroll_right",
668 }
669}
670
671fn clipboard_source_to_str(source: ClipboardSource) -> &'static str {
672 match source {
673 ClipboardSource::Osc52 => "osc52",
674 ClipboardSource::Unknown => "unknown",
675 }
676}
677
678fn ime_phase_to_str(phase: ImePhase) -> &'static str {
679 match phase {
680 ImePhase::Start => "start",
681 ImePhase::Update => "update",
682 ImePhase::Commit => "commit",
683 ImePhase::Cancel => "cancel",
684 }
685}
686
687impl TraceRecord {
688 pub fn to_jsonl(&self) -> String {
690 match self {
691 TraceRecord::Header {
692 seed,
693 cols,
694 rows,
695 profile,
696 } => format!(
697 r#"{{"schema_version":"{}","event":"trace_header","seed":{},"cols":{},"rows":{},"env":{{"target":"web"}},"profile":"{}"}}"#,
698 SCHEMA_VERSION,
699 seed,
700 cols,
701 rows,
702 json_escape(profile)
703 ),
704 TraceRecord::Input { ts_ns, event } => format!(
705 r#"{{"schema_version":"{}","event":"input","ts_ns":{},"data":{}}}"#,
706 SCHEMA_VERSION,
707 ts_ns,
708 event_to_json(event)
709 ),
710 TraceRecord::Resize { ts_ns, cols, rows } => format!(
711 r#"{{"schema_version":"{}","event":"resize","ts_ns":{},"cols":{},"rows":{}}}"#,
712 SCHEMA_VERSION, ts_ns, cols, rows
713 ),
714 TraceRecord::Tick { ts_ns } => format!(
715 r#"{{"schema_version":"{}","event":"tick","ts_ns":{}}}"#,
716 SCHEMA_VERSION, ts_ns
717 ),
718 TraceRecord::Frame {
719 frame_idx,
720 ts_ns,
721 checksum,
722 checksum_chain,
723 } => format!(
724 r#"{{"schema_version":"{}","event":"frame","frame_idx":{},"ts_ns":{},"hash_algo":"fnv1a64","frame_hash":"{:016x}","checksum_chain":"{:016x}"}}"#,
725 SCHEMA_VERSION, frame_idx, ts_ns, checksum, checksum_chain
726 ),
727 TraceRecord::Summary {
728 total_frames,
729 final_checksum_chain,
730 } => format!(
731 r#"{{"schema_version":"{}","event":"trace_summary","total_frames":{},"final_checksum_chain":"{:016x}"}}"#,
732 SCHEMA_VERSION, total_frames, final_checksum_chain
733 ),
734 }
735 }
736}
737
738impl SessionTrace {
739 pub fn to_jsonl(&self) -> String {
741 let mut out = String::new();
742 for record in &self.records {
743 out.push_str(&record.to_jsonl());
744 out.push('\n');
745 }
746 out
747 }
748
749 pub fn from_jsonl(input: &str) -> Result<Self, TraceParseError> {
753 let mut records = Vec::new();
754 for (line_num, line) in input.lines().enumerate() {
755 let line = line.trim();
756 if line.is_empty() {
757 continue;
758 }
759 let record = parse_trace_line(line, line_num + 1)?;
760 records.push(record);
761 }
762 Ok(SessionTrace { records })
763 }
764
765 pub fn from_jsonl_validated(input: &str) -> Result<Self, TraceLoadError> {
767 let trace = Self::from_jsonl(input)?;
768 trace.validate()?;
769 Ok(trace)
770 }
771}
772
773#[derive(Debug, Clone, PartialEq, Eq)]
775pub struct TraceParseError {
776 pub line: usize,
777 pub message: String,
778}
779
780impl core::fmt::Display for TraceParseError {
781 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
782 write!(f, "line {}: {}", self.line, self.message)
783 }
784}
785
786impl std::error::Error for TraceParseError {}
787
788#[derive(Debug, Clone, PartialEq, Eq)]
790pub enum TraceValidationError {
791 EmptyTrace,
792 MissingHeader,
793 HeaderNotFirst,
794 MultipleHeaders,
795 MissingSummary,
796 MultipleSummaries,
797 SummaryNotLast {
798 summary_index: usize,
799 },
800 TimestampRegression {
801 previous: u64,
802 current: u64,
803 record_index: usize,
804 },
805 FrameIndexMismatch {
806 expected: u64,
807 actual: u64,
808 },
809 SummaryFrameCountMismatch {
810 expected: u64,
811 actual: u64,
812 },
813 SummaryChecksumChainMismatch {
814 expected: u64,
815 actual: u64,
816 },
817}
818
819impl core::fmt::Display for TraceValidationError {
820 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
821 match self {
822 Self::EmptyTrace => write!(f, "trace is empty"),
823 Self::MissingHeader => write!(f, "trace is missing header"),
824 Self::HeaderNotFirst => write!(f, "trace header is not the first record"),
825 Self::MultipleHeaders => write!(f, "trace contains multiple headers"),
826 Self::MissingSummary => write!(f, "trace is missing summary"),
827 Self::MultipleSummaries => write!(f, "trace contains multiple summaries"),
828 Self::SummaryNotLast { summary_index } => write!(
829 f,
830 "trace summary at index {} is not the final record",
831 summary_index
832 ),
833 Self::TimestampRegression {
834 previous,
835 current,
836 record_index,
837 } => write!(
838 f,
839 "timestamp regression at record {}: current ts_ns={} is less than previous ts_ns={}",
840 record_index, current, previous
841 ),
842 Self::FrameIndexMismatch { expected, actual } => {
843 write!(
844 f,
845 "frame index mismatch: expected {}, got {}",
846 expected, actual
847 )
848 }
849 Self::SummaryFrameCountMismatch { expected, actual } => write!(
850 f,
851 "summary frame-count mismatch: expected {}, got {}",
852 expected, actual
853 ),
854 Self::SummaryChecksumChainMismatch { expected, actual } => write!(
855 f,
856 "summary checksum-chain mismatch: expected {:016x}, got {:016x}",
857 expected, actual
858 ),
859 }
860 }
861}
862
863impl std::error::Error for TraceValidationError {}
864
865#[derive(Debug, Clone, PartialEq, Eq)]
867pub enum TraceLoadError {
868 Parse(TraceParseError),
869 Validation(TraceValidationError),
870}
871
872impl core::fmt::Display for TraceLoadError {
873 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
874 match self {
875 Self::Parse(e) => write!(f, "{e}"),
876 Self::Validation(e) => write!(f, "{e}"),
877 }
878 }
879}
880
881impl std::error::Error for TraceLoadError {}
882
883impl From<TraceParseError> for TraceLoadError {
884 fn from(value: TraceParseError) -> Self {
885 Self::Parse(value)
886 }
887}
888
889impl From<TraceValidationError> for TraceLoadError {
890 fn from(value: TraceValidationError) -> Self {
891 Self::Validation(value)
892 }
893}
894
895fn extract_str<'a>(json: &'a str, key: &str) -> Option<&'a str> {
898 let pattern = format!("\"{}\":\"", key);
899 let start = json.find(&pattern)? + pattern.len();
900 let rest = &json[start..];
901 let mut i = 0;
903 let bytes = rest.as_bytes();
904 while i < bytes.len() {
905 if bytes[i] == b'\\' {
906 i += 2; continue;
908 }
909 if bytes[i] == b'"' {
910 return Some(&rest[..i]);
911 }
912 i += 1;
913 }
914 None
915}
916
917fn extract_u64(json: &str, key: &str) -> Option<u64> {
918 let pattern = format!("\"{}\":", key);
919 let start = json.find(&pattern)? + pattern.len();
920 let rest = json[start..].trim_start();
921 let end = rest
922 .find(|c: char| !c.is_ascii_digit())
923 .unwrap_or(rest.len());
924 rest[..end].parse().ok()
925}
926
927fn extract_i64(json: &str, key: &str) -> Option<i64> {
928 let pattern = format!("\"{}\":", key);
929 let start = json.find(&pattern)? + pattern.len();
930 let rest = json[start..].trim_start();
931 let signed = rest.strip_prefix('-').is_some();
932 let digits = if signed { &rest[1..] } else { rest };
933 let end = digits
934 .find(|c: char| !c.is_ascii_digit())
935 .unwrap_or(digits.len());
936 if end == 0 {
937 return None;
938 }
939 let parsed: i64 = digits[..end].parse().ok()?;
940 Some(if signed { -parsed } else { parsed })
941}
942
943fn extract_u16(json: &str, key: &str) -> Option<u16> {
944 extract_u64(json, key).and_then(|v| u16::try_from(v).ok())
945}
946
947fn extract_bool(json: &str, key: &str) -> Option<bool> {
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("true") {
952 Some(true)
953 } else if rest.starts_with("false") {
954 Some(false)
955 } else {
956 None
957 }
958}
959
960fn extract_hex_u64(json: &str, key: &str) -> Option<u64> {
961 let s = extract_str(json, key)?;
962 u64::from_str_radix(s, 16).ok()
963}
964
965fn extract_object<'a>(json: &'a str, key: &str) -> Option<&'a str> {
966 let pattern = format!("\"{}\":", key);
967 let start = json.find(&pattern)? + pattern.len();
968 let rest = json[start..].trim_start();
969 if !rest.starts_with('{') {
970 return None;
971 }
972 let mut depth = 0;
973 for (i, ch) in rest.char_indices() {
974 match ch {
975 '{' => depth += 1,
976 '}' => {
977 depth -= 1;
978 if depth == 0 {
979 return Some(&rest[..=i]);
980 }
981 }
982 _ => {}
983 }
984 }
985 None
986}
987
988fn json_unescape(input: &str) -> String {
989 let mut out = String::with_capacity(input.len());
990 let mut chars = input.chars();
991 while let Some(ch) = chars.next() {
992 if ch == '\\' {
993 match chars.next() {
994 Some('"') => out.push('"'),
995 Some('\\') => out.push('\\'),
996 Some('n') => out.push('\n'),
997 Some('r') => out.push('\r'),
998 Some('t') => out.push('\t'),
999 Some('u') => {
1000 let hex: String = chars.by_ref().take(4).collect();
1001 if let Ok(cp) = u32::from_str_radix(&hex, 16)
1002 && let Some(c) = char::from_u32(cp)
1003 {
1004 out.push(c);
1005 }
1006 }
1007 Some(c) => {
1008 out.push('\\');
1009 out.push(c);
1010 }
1011 None => out.push('\\'),
1012 }
1013 } else {
1014 out.push(ch);
1015 }
1016 }
1017 out
1018}
1019
1020fn check_trace_schema_compat(schema_version: &str, line_num: usize) -> Result<(), TraceParseError> {
1021 let incompatible = schema_version != SCHEMA_VERSION;
1022
1023 #[cfg(feature = "tracing")]
1024 {
1025 let span = info_span!(
1026 "trace.compat_check",
1027 reader_schema_version = SCHEMA_VERSION,
1028 writer_schema_version = schema_version,
1029 line = line_num,
1030 compatible = !incompatible,
1031 );
1032 let _guard = span.enter();
1033
1034 if incompatible {
1035 error!(
1036 reader_schema_version = SCHEMA_VERSION,
1037 writer_schema_version = schema_version,
1038 line = line_num,
1039 "trace schema version incompatible"
1040 );
1041 }
1042 }
1043
1044 if incompatible {
1045 return Err(TraceParseError {
1046 line: line_num,
1047 message: format!(
1048 "unsupported schema_version: {schema_version} (reader={SCHEMA_VERSION}, migration required)"
1049 ),
1050 });
1051 }
1052 Ok(())
1053}
1054
1055fn parse_trace_line(line: &str, line_num: usize) -> Result<TraceRecord, TraceParseError> {
1056 let err = |msg: &str| TraceParseError {
1057 line: line_num,
1058 message: msg.to_string(),
1059 };
1060
1061 let schema_version = extract_str(line, "schema_version")
1062 .ok_or_else(|| err("missing \"schema_version\" field"))?;
1063 check_trace_schema_compat(schema_version, line_num)?;
1064
1065 let event = extract_str(line, "event").ok_or_else(|| err("missing \"event\" field"))?;
1066
1067 match event {
1068 "trace_header" => {
1069 let seed = extract_u64(line, "seed").unwrap_or(0);
1070 let cols = extract_u16(line, "cols").ok_or_else(|| err("missing cols"))?;
1071 let rows = extract_u16(line, "rows").ok_or_else(|| err("missing rows"))?;
1072 let profile = extract_str(line, "profile")
1073 .map(|s| s.to_string())
1074 .unwrap_or_else(|| "modern".to_string());
1075 Ok(TraceRecord::Header {
1076 seed,
1077 cols,
1078 rows,
1079 profile,
1080 })
1081 }
1082 "input" => {
1083 let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
1084 let data = extract_object(line, "data").ok_or_else(|| err("missing data object"))?;
1085 let event = parse_event_json(data).map_err(|e| err(&e))?;
1086 Ok(TraceRecord::Input { ts_ns, event })
1087 }
1088 "resize" => {
1089 let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
1090 let cols = extract_u16(line, "cols").ok_or_else(|| err("missing cols"))?;
1091 let rows = extract_u16(line, "rows").ok_or_else(|| err("missing rows"))?;
1092 Ok(TraceRecord::Resize { ts_ns, cols, rows })
1093 }
1094 "tick" => {
1095 let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
1096 Ok(TraceRecord::Tick { ts_ns })
1097 }
1098 "frame" => {
1099 let frame_idx =
1100 extract_u64(line, "frame_idx").ok_or_else(|| err("missing frame_idx"))?;
1101 let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
1102 let checksum =
1103 extract_hex_u64(line, "frame_hash").ok_or_else(|| err("missing frame_hash"))?;
1104 let checksum_chain = extract_hex_u64(line, "checksum_chain")
1105 .ok_or_else(|| err("missing checksum_chain"))?;
1106 Ok(TraceRecord::Frame {
1107 frame_idx,
1108 ts_ns,
1109 checksum,
1110 checksum_chain,
1111 })
1112 }
1113 "trace_summary" => {
1114 let total_frames =
1115 extract_u64(line, "total_frames").ok_or_else(|| err("missing total_frames"))?;
1116 let final_checksum_chain = extract_hex_u64(line, "final_checksum_chain")
1117 .ok_or_else(|| err("missing final_checksum_chain"))?;
1118 Ok(TraceRecord::Summary {
1119 total_frames,
1120 final_checksum_chain,
1121 })
1122 }
1123 other => Err(err(&format!("unknown event type: {other}"))),
1124 }
1125}
1126
1127fn parse_event_json(data: &str) -> Result<Event, String> {
1128 let kind = extract_str(data, "kind").ok_or("missing event kind")?;
1129 match kind {
1130 "key" => {
1131 let code_str = extract_str(data, "code").ok_or("missing key code")?;
1132 let code = parse_key_code(&json_unescape(code_str))?;
1133 let mods_bits = extract_u64(data, "modifiers")
1134 .or(extract_u64(data, "mods"))
1135 .unwrap_or(0) as u8;
1136 let modifiers = Modifiers::from_bits(mods_bits).unwrap_or_else(Modifiers::empty);
1137 let event_kind = if let Some(event_kind_str) = extract_str(data, "event_kind") {
1138 match event_kind_str {
1139 "press" => KeyEventKind::Press,
1140 "repeat" => KeyEventKind::Repeat,
1141 "release" => KeyEventKind::Release,
1142 _ => KeyEventKind::Press,
1143 }
1144 } else {
1145 let phase = extract_str(data, "phase").unwrap_or("down");
1146 let repeat = extract_bool(data, "repeat").unwrap_or(false);
1147 parse_key_event_kind(phase, repeat)
1148 };
1149 Ok(Event::Key(KeyEvent {
1150 code,
1151 modifiers,
1152 kind: event_kind,
1153 }))
1154 }
1155 "mouse" => {
1156 let mouse_kind = if let Some(mouse_kind_str) = extract_str(data, "mouse_kind") {
1157 parse_mouse_event_kind(mouse_kind_str)?
1158 } else {
1159 let phase = extract_str(data, "phase").ok_or("missing phase for mouse event")?;
1160 let button = extract_u64(data, "button")
1161 .map(|raw| {
1162 u8::try_from(raw).map_err(|_| "mouse button out of range".to_string())
1163 })
1164 .transpose()?;
1165 parse_mouse_phase_and_button(phase, button)?
1166 };
1167 let x = extract_u16(data, "x").unwrap_or(0);
1168 let y = extract_u16(data, "y").unwrap_or(0);
1169 let mods_bits = extract_u64(data, "modifiers")
1170 .or(extract_u64(data, "mods"))
1171 .unwrap_or(0) as u8;
1172 let modifiers = Modifiers::from_bits(mods_bits).unwrap_or_else(Modifiers::empty);
1173 Ok(Event::Mouse(MouseEvent {
1174 kind: mouse_kind,
1175 x,
1176 y,
1177 modifiers,
1178 }))
1179 }
1180 "wheel" => {
1181 let x = extract_u16(data, "x").unwrap_or(0);
1182 let y = extract_u16(data, "y").unwrap_or(0);
1183 let dx = extract_i64(data, "dx")
1184 .and_then(|value| i16::try_from(value).ok())
1185 .unwrap_or(0);
1186 let dy = extract_i64(data, "dy")
1187 .and_then(|value| i16::try_from(value).ok())
1188 .unwrap_or(0);
1189 let kind = if dy < 0 {
1190 MouseEventKind::ScrollUp
1191 } else if dy > 0 {
1192 MouseEventKind::ScrollDown
1193 } else if dx < 0 {
1194 MouseEventKind::ScrollLeft
1195 } else if dx > 0 {
1196 MouseEventKind::ScrollRight
1197 } else {
1198 return Err("wheel event must include non-zero dx or dy".to_string());
1199 };
1200 let mods_bits = extract_u64(data, "modifiers")
1201 .or(extract_u64(data, "mods"))
1202 .unwrap_or(0) as u8;
1203 let modifiers = Modifiers::from_bits(mods_bits).unwrap_or_else(Modifiers::empty);
1204 Ok(Event::Mouse(MouseEvent {
1205 kind,
1206 x,
1207 y,
1208 modifiers,
1209 }))
1210 }
1211 "resize" => {
1212 let width = extract_u16(data, "width").ok_or("missing width")?;
1213 let height = extract_u16(data, "height").ok_or("missing height")?;
1214 Ok(Event::Resize { width, height })
1215 }
1216 "paste" => {
1217 let text = extract_str(data, "text")
1218 .or(extract_str(data, "data"))
1219 .map(json_unescape)
1220 .unwrap_or_default();
1221 let bracketed = extract_bool(data, "bracketed").unwrap_or(true);
1222 Ok(Event::Paste(PasteEvent::new(text, bracketed)))
1223 }
1224 "ime" | "composition" => {
1225 let phase_raw = extract_str(data, "phase").unwrap_or("update");
1226 let phase = parse_ime_phase(phase_raw)?;
1227 let text = extract_str(data, "text")
1228 .or(extract_str(data, "data"))
1229 .map(json_unescape)
1230 .unwrap_or_default();
1231 let ime = match phase {
1232 ImePhase::Start => ImeEvent::start(),
1233 ImePhase::Update => ImeEvent::update(text),
1234 ImePhase::Commit => ImeEvent::commit(text),
1235 ImePhase::Cancel => ImeEvent::cancel(),
1236 };
1237 Ok(Event::Ime(ime))
1238 }
1239 "focus" => {
1240 let gained = extract_bool(data, "gained")
1241 .or(extract_bool(data, "focused"))
1242 .unwrap_or(true);
1243 Ok(Event::Focus(gained))
1244 }
1245 "clipboard" => {
1246 let content = extract_str(data, "content")
1247 .map(json_unescape)
1248 .unwrap_or_default();
1249 let source_str = extract_str(data, "source").unwrap_or("unknown");
1250 let source = match source_str {
1251 "osc52" => ClipboardSource::Osc52,
1252 _ => ClipboardSource::Unknown,
1253 };
1254 Ok(Event::Clipboard(ClipboardEvent::new(content, source)))
1255 }
1256 "tick" => Ok(Event::Tick),
1257 other => Err(format!("unknown event kind: {other}")),
1258 }
1259}
1260
1261fn parse_ime_phase(phase: &str) -> Result<ImePhase, String> {
1262 match phase {
1263 "start" => Ok(ImePhase::Start),
1264 "update" => Ok(ImePhase::Update),
1265 "end" | "commit" => Ok(ImePhase::Commit),
1266 "cancel" => Ok(ImePhase::Cancel),
1267 other => Err(format!("unknown ime/composition phase: {other}")),
1268 }
1269}
1270
1271fn parse_key_code(s: &str) -> Result<KeyCode, String> {
1272 if let Some(rest) = s.strip_prefix("char:") {
1273 let ch = rest.chars().next().ok_or("empty char code")?;
1274 return Ok(KeyCode::Char(ch));
1275 }
1276 if let Some(rest) = s.strip_prefix("f:") {
1277 let n: u8 = rest.parse().map_err(|_| "invalid F-key number")?;
1278 return Ok(KeyCode::F(n));
1279 }
1280 if let Some(n) = parse_function_key_token(s) {
1281 return Ok(KeyCode::F(n));
1282 }
1283
1284 let mut chars = s.chars();
1285 if let Some(ch) = chars.next()
1286 && chars.next().is_none()
1287 {
1288 return Ok(KeyCode::Char(ch));
1289 }
1290
1291 let normalized = s.to_ascii_lowercase();
1292 match normalized.as_str() {
1293 "enter" | "return" => Ok(KeyCode::Enter),
1294 "escape" | "esc" => Ok(KeyCode::Escape),
1295 "backspace" => Ok(KeyCode::Backspace),
1296 "tab" => Ok(KeyCode::Tab),
1297 "backtab" => Ok(KeyCode::BackTab),
1298 "delete" => Ok(KeyCode::Delete),
1299 "insert" => Ok(KeyCode::Insert),
1300 "home" => Ok(KeyCode::Home),
1301 "end" => Ok(KeyCode::End),
1302 "pageup" => Ok(KeyCode::PageUp),
1303 "pagedown" => Ok(KeyCode::PageDown),
1304 "up" | "arrowup" => Ok(KeyCode::Up),
1305 "down" | "arrowdown" => Ok(KeyCode::Down),
1306 "left" | "arrowleft" => Ok(KeyCode::Left),
1307 "right" | "arrowright" => Ok(KeyCode::Right),
1308 "null" | "unidentified" => Ok(KeyCode::Null),
1309 "media_play_pause" => Ok(KeyCode::MediaPlayPause),
1310 "media_stop" => Ok(KeyCode::MediaStop),
1311 "media_next" => Ok(KeyCode::MediaNextTrack),
1312 "media_prev" => Ok(KeyCode::MediaPrevTrack),
1313 other => Err(format!("unknown key code: {other}")),
1314 }
1315}
1316
1317fn parse_function_key_token(s: &str) -> Option<u8> {
1318 let rest = s.strip_prefix('F').or_else(|| s.strip_prefix('f'))?;
1319 if rest.is_empty() || !rest.chars().all(|ch| ch.is_ascii_digit()) {
1320 return None;
1321 }
1322 rest.parse().ok()
1323}
1324
1325fn parse_key_event_kind(phase: &str, repeat: bool) -> KeyEventKind {
1326 if phase.eq_ignore_ascii_case("up") || phase.eq_ignore_ascii_case("release") {
1327 KeyEventKind::Release
1328 } else if repeat {
1329 KeyEventKind::Repeat
1330 } else {
1331 KeyEventKind::Press
1332 }
1333}
1334
1335fn parse_mouse_event_kind(s: &str) -> Result<MouseEventKind, String> {
1336 match s {
1337 "down_left" => Ok(MouseEventKind::Down(MouseButton::Left)),
1338 "down_right" => Ok(MouseEventKind::Down(MouseButton::Right)),
1339 "down_middle" => Ok(MouseEventKind::Down(MouseButton::Middle)),
1340 "up_left" => Ok(MouseEventKind::Up(MouseButton::Left)),
1341 "up_right" => Ok(MouseEventKind::Up(MouseButton::Right)),
1342 "up_middle" => Ok(MouseEventKind::Up(MouseButton::Middle)),
1343 "drag_left" => Ok(MouseEventKind::Drag(MouseButton::Left)),
1344 "drag_right" => Ok(MouseEventKind::Drag(MouseButton::Right)),
1345 "drag_middle" => Ok(MouseEventKind::Drag(MouseButton::Middle)),
1346 "moved" => Ok(MouseEventKind::Moved),
1347 "scroll_up" => Ok(MouseEventKind::ScrollUp),
1348 "scroll_down" => Ok(MouseEventKind::ScrollDown),
1349 "scroll_left" => Ok(MouseEventKind::ScrollLeft),
1350 "scroll_right" => Ok(MouseEventKind::ScrollRight),
1351 other => Err(format!("unknown mouse event kind: {other}")),
1352 }
1353}
1354
1355fn parse_mouse_phase_and_button(phase: &str, button: Option<u8>) -> Result<MouseEventKind, String> {
1356 match phase {
1357 "down" => Ok(MouseEventKind::Down(parse_mouse_button(
1358 button.ok_or("mouse down requires button")?,
1359 )?)),
1360 "up" => Ok(MouseEventKind::Up(parse_mouse_button(
1361 button.ok_or("mouse up requires button")?,
1362 )?)),
1363 "drag" => Ok(MouseEventKind::Drag(parse_mouse_button(
1364 button.ok_or("mouse drag requires button")?,
1365 )?)),
1366 "move" => Ok(MouseEventKind::Moved),
1367 other => Err(format!("unknown mouse phase: {other}")),
1368 }
1369}
1370
1371fn parse_mouse_button(raw: u8) -> Result<MouseButton, String> {
1372 match raw {
1373 0 => Ok(MouseButton::Left),
1374 1 => Ok(MouseButton::Middle),
1375 2 => Ok(MouseButton::Right),
1376 other => Err(format!("unsupported mouse button: {other}")),
1377 }
1378}
1379
1380pub fn gate_trace<M: ftui_runtime::program::Model>(
1388 model: M,
1389 trace: &SessionTrace,
1390) -> Result<GateReport, ReplayError> {
1391 let result = replay(model, trace)?;
1392
1393 let frame_checksums: Vec<(u64, u64)> = trace
1394 .records
1395 .iter()
1396 .filter_map(|r| match r {
1397 TraceRecord::Frame {
1398 frame_idx,
1399 checksum,
1400 ..
1401 } => Some((*frame_idx, *checksum)),
1402 _ => None,
1403 })
1404 .collect();
1405
1406 let diff = result.first_mismatch.as_ref().map(|m| {
1407 let mut event_idx: u64 = 0;
1409 let mut last_event_desc = String::new();
1410 let mut frame_count: u64 = 0;
1411 for record in &trace.records {
1412 match record {
1413 TraceRecord::Frame { .. } => {
1414 if frame_count == m.frame_idx {
1415 break;
1416 }
1417 frame_count += 1;
1418 }
1419 TraceRecord::Input { event, .. } => {
1420 last_event_desc = format!("{event:?}");
1421 event_idx += 1;
1422 }
1423 TraceRecord::Resize { cols, rows, .. } => {
1424 last_event_desc = format!("Resize({cols}x{rows})");
1425 event_idx += 1;
1426 }
1427 TraceRecord::Tick { ts_ns } => {
1428 last_event_desc = format!("Tick(ts_ns={ts_ns})");
1429 event_idx += 1;
1430 }
1431 _ => {}
1432 }
1433 }
1434
1435 GateDiff {
1436 frame_idx: m.frame_idx,
1437 event_idx,
1438 last_event: last_event_desc,
1439 expected_checksum: m.expected,
1440 actual_checksum: m.actual,
1441 }
1442 });
1443
1444 Ok(GateReport {
1445 passed: result.ok(),
1446 total_frames: result.total_frames,
1447 expected_frames: frame_checksums.len() as u64,
1448 final_checksum_chain: result.final_checksum_chain,
1449 diff,
1450 })
1451}
1452
1453#[derive(Debug, Clone)]
1455pub struct GateReport {
1456 pub passed: bool,
1458 pub total_frames: u64,
1460 pub expected_frames: u64,
1462 pub final_checksum_chain: u64,
1464 pub diff: Option<GateDiff>,
1466}
1467
1468impl GateReport {
1469 pub fn format(&self) -> String {
1471 if self.passed {
1472 format!(
1473 "PASS: {}/{} frames verified, final_chain={:016x}",
1474 self.total_frames, self.expected_frames, self.final_checksum_chain
1475 )
1476 } else if let Some(d) = &self.diff {
1477 format!(
1478 "FAIL at frame {} (after event #{}: {}): expected {:016x}, got {:016x}",
1479 d.frame_idx, d.event_idx, d.last_event, d.expected_checksum, d.actual_checksum
1480 )
1481 } else {
1482 format!(
1483 "FAIL: {}/{} frames, unknown mismatch",
1484 self.total_frames, self.expected_frames
1485 )
1486 }
1487 }
1488}
1489
1490#[derive(Debug, Clone)]
1492pub struct GateDiff {
1493 pub frame_idx: u64,
1495 pub event_idx: u64,
1497 pub last_event: String,
1499 pub expected_checksum: u64,
1501 pub actual_checksum: u64,
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507 use super::*;
1508 use ftui_core::event::{
1509 KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
1510 PasteEvent,
1511 };
1512 use ftui_render::cell::Cell;
1513 use ftui_render::frame::Frame;
1514 use ftui_runtime::program::{Cmd, Model};
1515 use pretty_assertions::assert_eq;
1516 #[cfg(feature = "tracing")]
1517 use std::sync::{Arc, Mutex};
1518 #[cfg(feature = "tracing")]
1519 use tracing::Subscriber;
1520 #[cfg(feature = "tracing")]
1521 use tracing::field::{Field, Visit};
1522 #[cfg(feature = "tracing")]
1523 use tracing_subscriber::Layer;
1524 #[cfg(feature = "tracing")]
1525 use tracing_subscriber::filter::LevelFilter;
1526 #[cfg(feature = "tracing")]
1527 use tracing_subscriber::layer::{Context, SubscriberExt};
1528 #[cfg(feature = "tracing")]
1529 use tracing_subscriber::registry::LookupSpan;
1530
1531 #[cfg(feature = "tracing")]
1532 #[derive(Default, Clone)]
1533 struct TraceCaptureLayer {
1534 spans: Arc<Mutex<Vec<String>>>,
1535 events: Arc<Mutex<Vec<String>>>,
1536 }
1537
1538 #[cfg(feature = "tracing")]
1539 #[derive(Default)]
1540 struct EventMessageVisitor {
1541 message: Option<String>,
1542 }
1543
1544 #[cfg(feature = "tracing")]
1545 impl Visit for EventMessageVisitor {
1546 fn record_str(&mut self, field: &Field, value: &str) {
1547 if field.name() == "message" {
1548 self.message = Some(value.to_string());
1549 }
1550 }
1551
1552 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
1553 if field.name() == "message" {
1554 self.message = Some(format!("{value:?}"));
1555 }
1556 }
1557 }
1558
1559 #[cfg(feature = "tracing")]
1560 impl<S> Layer<S> for TraceCaptureLayer
1561 where
1562 S: Subscriber + for<'lookup> LookupSpan<'lookup>,
1563 {
1564 fn on_new_span(
1565 &self,
1566 attrs: &tracing::span::Attributes<'_>,
1567 _id: &tracing::span::Id,
1568 _ctx: Context<'_, S>,
1569 ) {
1570 self.spans
1571 .lock()
1572 .expect("span capture lock")
1573 .push(attrs.metadata().name().to_string());
1574 }
1575
1576 fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
1577 let mut visitor = EventMessageVisitor::default();
1578 event.record(&mut visitor);
1579 let message = visitor.message.unwrap_or_default();
1580 self.events
1581 .lock()
1582 .expect("event capture lock")
1583 .push(format!("{}:{}", event.metadata().level(), message));
1584 }
1585 }
1586
1587 struct Counter {
1590 value: i32,
1591 }
1592
1593 #[derive(Debug)]
1594 enum CounterMsg {
1595 Increment,
1596 Decrement,
1597 Reset,
1598 Quit,
1599 }
1600
1601 impl From<Event> for CounterMsg {
1602 fn from(event: Event) -> Self {
1603 match event {
1604 Event::Key(k) if k.code == KeyCode::Char('+') => CounterMsg::Increment,
1605 Event::Key(k) if k.code == KeyCode::Char('-') => CounterMsg::Decrement,
1606 Event::Key(k) if k.code == KeyCode::Char('r') => CounterMsg::Reset,
1607 Event::Key(k) if k.code == KeyCode::Char('q') => CounterMsg::Quit,
1608 Event::Tick => CounterMsg::Increment,
1609 _ => CounterMsg::Increment,
1610 }
1611 }
1612 }
1613
1614 impl Model for Counter {
1615 type Message = CounterMsg;
1616
1617 fn init(&mut self) -> Cmd<Self::Message> {
1618 Cmd::none()
1619 }
1620
1621 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
1622 match msg {
1623 CounterMsg::Increment => {
1624 self.value += 1;
1625 Cmd::none()
1626 }
1627 CounterMsg::Decrement => {
1628 self.value -= 1;
1629 Cmd::none()
1630 }
1631 CounterMsg::Reset => {
1632 self.value = 0;
1633 Cmd::none()
1634 }
1635 CounterMsg::Quit => Cmd::quit(),
1636 }
1637 }
1638
1639 fn view(&self, frame: &mut Frame) {
1640 let text = format!("Count: {}", self.value);
1641 for (i, c) in text.chars().enumerate() {
1642 if (i as u16) < frame.width() {
1643 frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
1644 }
1645 }
1646 }
1647 }
1648
1649 fn key_event(c: char) -> Event {
1650 Event::Key(KeyEvent {
1651 code: KeyCode::Char(c),
1652 modifiers: Modifiers::empty(),
1653 kind: KeyEventKind::Press,
1654 })
1655 }
1656
1657 fn parse_single_input_event(data_json: &str) -> Event {
1658 let line = format!(
1659 r#"{{"schema_version":"{}","event":"input","ts_ns":0,"data":{}}}"#,
1660 SCHEMA_VERSION, data_json
1661 );
1662 let trace = SessionTrace::from_jsonl(&line).expect("input JSON should parse");
1663 trace
1664 .records
1665 .into_iter()
1666 .next()
1667 .and_then(|record| match record {
1668 TraceRecord::Input { event, .. } => Some(event),
1669 _ => None,
1670 })
1671 .expect("expected single input record")
1672 }
1673
1674 fn new_counter(value: i32) -> Counter {
1675 Counter { value }
1676 }
1677
1678 #[test]
1681 fn fnv1a64_pair_is_deterministic() {
1682 let a = fnv1a64_pair(0, 1234);
1683 let b = fnv1a64_pair(0, 1234);
1684 assert_eq!(a, b);
1685 }
1686
1687 #[test]
1688 fn fnv1a64_pair_differs_for_different_input() {
1689 assert_ne!(fnv1a64_pair(0, 1), fnv1a64_pair(0, 2));
1690 assert_ne!(fnv1a64_pair(1, 0), fnv1a64_pair(2, 0));
1691 }
1692
1693 #[test]
1696 fn recorder_produces_header_and_summary() {
1697 let mut rec = SessionRecorder::new(new_counter(0), 80, 24, 42);
1698 rec.init().unwrap();
1699
1700 let trace = rec.finish();
1701 assert!(trace.records.len() >= 3); assert!(matches!(
1705 &trace.records[0],
1706 TraceRecord::Header {
1707 seed: 42,
1708 cols: 80,
1709 rows: 24,
1710 ..
1711 }
1712 ));
1713
1714 assert!(matches!(
1716 trace.records.last().unwrap(),
1717 TraceRecord::Summary {
1718 total_frames: 1,
1719 ..
1720 }
1721 ));
1722 }
1723
1724 #[test]
1725 fn recorder_captures_init_frame() {
1726 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1727 rec.init().unwrap();
1728
1729 let trace = rec.finish();
1730 let frames: Vec<_> = trace
1731 .records
1732 .iter()
1733 .filter(|r| matches!(r, TraceRecord::Frame { .. }))
1734 .collect();
1735 assert_eq!(frames.len(), 1);
1736
1737 if let TraceRecord::Frame {
1738 frame_idx,
1739 checksum,
1740 ..
1741 } = &frames[0]
1742 {
1743 assert_eq!(*frame_idx, 0);
1744 assert_ne!(*checksum, 0); }
1746 }
1747
1748 #[test]
1751 fn record_replay_identical_checksums() {
1752 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1754 rec.init().unwrap();
1755
1756 rec.push_event(1_000_000, key_event('+'));
1757 rec.push_event(2_000_000, key_event('+'));
1758 rec.push_event(3_000_000, key_event('-'));
1759 rec.step().unwrap();
1760
1761 rec.push_event(16_000_000, key_event('+'));
1762 rec.step().unwrap();
1763
1764 let trace = rec.finish();
1765 assert_eq!(trace.frame_count(), 3); let result = replay(new_counter(0), &trace).unwrap();
1769 assert!(result.ok(), "replay mismatch: {:?}", result.first_mismatch);
1770 assert_eq!(result.total_frames, 3);
1771 assert_eq!(
1772 result.final_checksum_chain,
1773 trace.final_checksum_chain().unwrap()
1774 );
1775 }
1776
1777 #[test]
1778 fn replay_detects_different_initial_state() {
1779 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1781 rec.init().unwrap();
1782 let trace = rec.finish();
1783
1784 let result = replay(new_counter(5), &trace).unwrap();
1786 assert!(!result.ok());
1787 assert_eq!(result.first_mismatch.as_ref().unwrap().frame_idx, 0);
1788 }
1789
1790 #[test]
1791 fn replay_detects_divergence_after_events() {
1792 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1794 rec.init().unwrap();
1795
1796 rec.push_event(1_000_000, key_event('+'));
1797 rec.push_event(2_000_000, key_event('+'));
1798 rec.step().unwrap();
1799
1800 let trace = rec.finish();
1801
1802 let result = replay(new_counter(1), &trace).unwrap();
1804 assert!(!result.ok());
1805 }
1806
1807 #[test]
1810 fn resize_is_recorded_and_replayed() {
1811 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1812 rec.init().unwrap();
1813
1814 rec.resize(5_000_000, 40, 2);
1815 rec.step().unwrap();
1816
1817 let trace = rec.finish();
1818
1819 assert!(trace.records.iter().any(|r| matches!(
1821 r,
1822 TraceRecord::Resize {
1823 cols: 40,
1824 rows: 2,
1825 ..
1826 }
1827 )));
1828
1829 let result = replay(new_counter(0), &trace).unwrap();
1831 assert!(
1832 result.ok(),
1833 "resize replay mismatch: {:?}",
1834 result.first_mismatch
1835 );
1836 }
1837
1838 #[test]
1841 fn multi_step_record_replay() {
1842 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1843 rec.init().unwrap();
1844
1845 for i in 0..5 {
1846 rec.push_event(i * 16_000_000, key_event('+'));
1847 rec.step().unwrap();
1848 }
1849
1850 let trace = rec.finish();
1851 assert_eq!(trace.frame_count(), 6); let result = replay(new_counter(0), &trace).unwrap();
1854 assert!(
1855 result.ok(),
1856 "multi-step mismatch: {:?}",
1857 result.first_mismatch
1858 );
1859 assert_eq!(result.total_frames, 6);
1860 }
1861
1862 #[test]
1865 fn quit_stops_recording() {
1866 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1867 rec.init().unwrap();
1868
1869 rec.push_event(1_000_000, key_event('+'));
1870 rec.push_event(2_000_000, key_event('q'));
1871 let result = rec.step().unwrap();
1872 assert!(!result.running);
1873
1874 let trace = rec.finish();
1875 assert_eq!(trace.frame_count(), 1);
1877 }
1878
1879 #[test]
1882 fn empty_session_replay() {
1883 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1884 rec.init().unwrap();
1885 let trace = rec.finish();
1886
1887 let result = replay(new_counter(0), &trace).unwrap();
1888 assert!(result.ok());
1889 assert_eq!(result.total_frames, 1); }
1891
1892 #[test]
1895 fn session_trace_frame_count() {
1896 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1897 rec.init().unwrap();
1898 rec.push_event(1_000_000, key_event('+'));
1899 rec.step().unwrap();
1900 let trace = rec.finish();
1901 assert_eq!(trace.frame_count(), 2);
1902 }
1903
1904 #[test]
1905 fn session_trace_final_checksum_chain() {
1906 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1907 rec.init().unwrap();
1908 let trace = rec.finish();
1909 assert!(trace.final_checksum_chain().is_some());
1910 assert_ne!(trace.final_checksum_chain().unwrap(), 0);
1911 }
1912
1913 #[test]
1916 fn replay_missing_header_returns_error() {
1917 let trace = SessionTrace { records: vec![] };
1918 let result = replay(new_counter(0), &trace);
1919 assert!(matches!(result, Err(ReplayError::MissingHeader)));
1920 }
1921
1922 #[test]
1923 fn replay_non_header_first_returns_error() {
1924 let trace = SessionTrace {
1925 records: vec![TraceRecord::Tick { ts_ns: 0 }],
1926 };
1927 let result = replay(new_counter(0), &trace);
1928 assert!(matches!(result, Err(ReplayError::MissingHeader)));
1929 }
1930
1931 #[test]
1932 fn trace_validate_missing_summary_returns_typed_error() {
1933 let trace = SessionTrace {
1934 records: vec![TraceRecord::Header {
1935 seed: 0,
1936 cols: 80,
1937 rows: 24,
1938 profile: "modern".to_string(),
1939 }],
1940 };
1941 let result = trace.validate();
1942 assert_eq!(result, Err(TraceValidationError::MissingSummary));
1943 }
1944
1945 #[test]
1946 fn trace_validate_summary_frame_count_mismatch_returns_typed_error() {
1947 let trace = SessionTrace {
1948 records: vec![
1949 TraceRecord::Header {
1950 seed: 0,
1951 cols: 80,
1952 rows: 24,
1953 profile: "modern".to_string(),
1954 },
1955 TraceRecord::Frame {
1956 frame_idx: 0,
1957 ts_ns: 0,
1958 checksum: 0x1,
1959 checksum_chain: 0x10,
1960 },
1961 TraceRecord::Summary {
1962 total_frames: 2,
1963 final_checksum_chain: 0x10,
1964 },
1965 ],
1966 };
1967 let result = trace.validate();
1968 assert_eq!(
1969 result,
1970 Err(TraceValidationError::SummaryFrameCountMismatch {
1971 expected: 1,
1972 actual: 2,
1973 })
1974 );
1975 }
1976
1977 #[test]
1978 fn trace_validate_frame_index_gap_returns_typed_error() {
1979 let trace = SessionTrace {
1980 records: vec![
1981 TraceRecord::Header {
1982 seed: 0,
1983 cols: 80,
1984 rows: 24,
1985 profile: "modern".to_string(),
1986 },
1987 TraceRecord::Frame {
1988 frame_idx: 1,
1989 ts_ns: 0,
1990 checksum: 0x1,
1991 checksum_chain: 0x10,
1992 },
1993 TraceRecord::Summary {
1994 total_frames: 1,
1995 final_checksum_chain: 0x10,
1996 },
1997 ],
1998 };
1999 let result = trace.validate();
2000 assert_eq!(
2001 result,
2002 Err(TraceValidationError::FrameIndexMismatch {
2003 expected: 0,
2004 actual: 1,
2005 })
2006 );
2007 }
2008
2009 #[test]
2010 fn trace_validate_timestamp_regression_returns_typed_error() {
2011 let trace = SessionTrace {
2012 records: vec![
2013 TraceRecord::Header {
2014 seed: 0,
2015 cols: 80,
2016 rows: 24,
2017 profile: "modern".to_string(),
2018 },
2019 TraceRecord::Tick { ts_ns: 20 },
2020 TraceRecord::Tick { ts_ns: 10 },
2021 TraceRecord::Summary {
2022 total_frames: 0,
2023 final_checksum_chain: 0,
2024 },
2025 ],
2026 };
2027 let result = trace.validate();
2028 assert_eq!(
2029 result,
2030 Err(TraceValidationError::TimestampRegression {
2031 previous: 20,
2032 current: 10,
2033 record_index: 2,
2034 })
2035 );
2036 }
2037
2038 #[test]
2039 fn replay_validates_trace_before_execution() {
2040 let trace = SessionTrace {
2041 records: vec![TraceRecord::Header {
2042 seed: 0,
2043 cols: 80,
2044 rows: 24,
2045 profile: "modern".to_string(),
2046 }],
2047 };
2048 let result = replay(new_counter(0), &trace);
2049 assert_eq!(
2050 result,
2051 Err(ReplayError::InvalidTrace(
2052 TraceValidationError::MissingSummary
2053 ))
2054 );
2055 }
2056
2057 #[test]
2060 fn same_inputs_produce_same_trace_checksums() {
2061 fn record_session() -> SessionTrace {
2062 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2063 rec.init().unwrap();
2064
2065 rec.push_event(1_000_000, key_event('+'));
2066 rec.push_event(2_000_000, key_event('+'));
2067 rec.push_event(3_000_000, key_event('-'));
2068 rec.step().unwrap();
2069
2070 rec.push_event(16_000_000, key_event('+'));
2071 rec.step().unwrap();
2072
2073 rec.finish()
2074 }
2075
2076 let t1 = record_session();
2077 let t2 = record_session();
2078 let t3 = record_session();
2079
2080 let checksums = |t: &SessionTrace| -> Vec<u64> {
2082 t.records
2083 .iter()
2084 .filter_map(|r| match r {
2085 TraceRecord::Frame { checksum, .. } => Some(*checksum),
2086 _ => None,
2087 })
2088 .collect()
2089 };
2090
2091 assert_eq!(checksums(&t1), checksums(&t2));
2092 assert_eq!(checksums(&t2), checksums(&t3));
2093 assert_eq!(t1.final_checksum_chain(), t2.final_checksum_chain());
2094 }
2095
2096 #[test]
2099 fn mouse_event_record_replay() {
2100 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2101 rec.init().unwrap();
2102
2103 let mouse = Event::Mouse(MouseEvent {
2104 kind: MouseEventKind::Down(MouseButton::Left),
2105 x: 5,
2106 y: 0,
2107 modifiers: Modifiers::empty(),
2108 });
2109 rec.push_event(1_000_000, mouse);
2110 rec.step().unwrap();
2111
2112 let trace = rec.finish();
2113 let result = replay(new_counter(0), &trace).unwrap();
2114 assert!(result.ok());
2115 }
2116
2117 #[test]
2118 fn paste_event_record_replay() {
2119 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2120 rec.init().unwrap();
2121
2122 let paste = Event::Paste(PasteEvent::bracketed("hello"));
2123 rec.push_event(1_000_000, paste);
2124 rec.step().unwrap();
2125
2126 let trace = rec.finish();
2127 let result = replay(new_counter(0), &trace).unwrap();
2128 assert!(result.ok());
2129 }
2130
2131 #[test]
2132 fn focus_event_record_replay() {
2133 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2134 rec.init().unwrap();
2135
2136 rec.push_event(1_000_000, Event::Focus(true));
2137 rec.push_event(2_000_000, Event::Focus(false));
2138 rec.step().unwrap();
2139
2140 let trace = rec.finish();
2141 let result = replay(new_counter(0), &trace).unwrap();
2142 assert!(result.ok());
2143 }
2144
2145 #[test]
2146 fn ime_event_record_replay() {
2147 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2148 rec.init().unwrap();
2149
2150 rec.push_event(1_000_000, Event::Ime(ImeEvent::start()));
2151 rec.push_event(2_000_000, Event::Ime(ImeEvent::update("你")));
2152 rec.push_event(3_000_000, Event::Ime(ImeEvent::commit("你好")));
2153 rec.step().unwrap();
2154
2155 let trace = rec.finish();
2156 let result = replay(new_counter(0), &trace).unwrap();
2157 assert!(result.ok());
2158 }
2159
2160 #[test]
2163 fn checksum_chain_is_cumulative() {
2164 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2165 rec.init().unwrap();
2166
2167 rec.push_event(1_000_000, key_event('+'));
2168 rec.step().unwrap();
2169
2170 rec.push_event(2_000_000, key_event('+'));
2171 rec.step().unwrap();
2172
2173 let trace = rec.finish();
2174 let frame_records: Vec<_> = trace
2175 .records
2176 .iter()
2177 .filter_map(|r| match r {
2178 TraceRecord::Frame {
2179 checksum,
2180 checksum_chain,
2181 ..
2182 } => Some((*checksum, *checksum_chain)),
2183 _ => None,
2184 })
2185 .collect();
2186
2187 assert_eq!(frame_records.len(), 3);
2188
2189 let (c0, chain0) = frame_records[0];
2191 assert_eq!(chain0, fnv1a64_pair(0, c0));
2192
2193 let (c1, chain1) = frame_records[1];
2194 assert_eq!(chain1, fnv1a64_pair(chain0, c1));
2195
2196 let (c2, chain2) = frame_records[2];
2197 assert_eq!(chain2, fnv1a64_pair(chain1, c2));
2198
2199 assert_eq!(trace.final_checksum_chain(), Some(chain2));
2201 }
2202
2203 #[test]
2206 fn recorder_exposes_program() {
2207 let mut rec = SessionRecorder::new(new_counter(42), 20, 1, 0);
2208 rec.init().unwrap();
2209 assert_eq!(rec.program().model().value, 42);
2210 }
2211
2212 #[test]
2215 fn replay_result_ok_when_no_mismatch() {
2216 let r = ReplayResult {
2217 total_frames: 5,
2218 final_checksum_chain: 123,
2219 first_mismatch: None,
2220 };
2221 assert!(r.ok());
2222 }
2223
2224 #[test]
2225 fn replay_result_not_ok_when_mismatch() {
2226 let r = ReplayResult {
2227 total_frames: 5,
2228 final_checksum_chain: 123,
2229 first_mismatch: Some(ReplayMismatch {
2230 frame_idx: 2,
2231 expected: 100,
2232 actual: 200,
2233 }),
2234 };
2235 assert!(!r.ok());
2236 }
2237
2238 #[test]
2239 fn replay_error_display() {
2240 assert_eq!(
2241 ReplayError::MissingHeader.to_string(),
2242 "trace missing header record"
2243 );
2244 let invalid = ReplayError::InvalidTrace(TraceValidationError::MissingSummary);
2245 assert_eq!(
2246 invalid.to_string(),
2247 "invalid trace: trace is missing summary"
2248 );
2249 let be = ReplayError::Backend(WebBackendError::Unsupported("test"));
2250 assert!(be.to_string().contains("test"));
2251 }
2252
2253 #[test]
2256 fn trace_record_header_to_jsonl() {
2257 let r = TraceRecord::Header {
2258 seed: 42,
2259 cols: 80,
2260 rows: 24,
2261 profile: "modern".to_string(),
2262 };
2263 let line = r.to_jsonl();
2264 assert!(line.contains("\"event\":\"trace_header\""));
2265 assert!(line.contains("\"schema_version\":\"golden-trace-v1\""));
2266 assert!(line.contains("\"seed\":42"));
2267 assert!(line.contains("\"cols\":80"));
2268 assert!(line.contains("\"rows\":24"));
2269 assert!(line.contains("\"profile\":\"modern\""));
2270 }
2271
2272 #[test]
2273 fn trace_record_input_key_to_jsonl() {
2274 let r = TraceRecord::Input {
2275 ts_ns: 1_000_000,
2276 event: key_event('+'),
2277 };
2278 let line = r.to_jsonl();
2279 assert!(line.contains("\"event\":\"input\""));
2280 assert!(line.contains("\"ts_ns\":1000000"));
2281 assert!(line.contains("\"kind\":\"key\""));
2282 assert!(line.contains("\"code\":\"char:+\""));
2283 }
2284
2285 #[test]
2286 fn trace_record_resize_to_jsonl() {
2287 let r = TraceRecord::Resize {
2288 ts_ns: 5_000_000,
2289 cols: 120,
2290 rows: 40,
2291 };
2292 let line = r.to_jsonl();
2293 assert!(line.contains("\"event\":\"resize\""));
2294 assert!(line.contains("\"cols\":120"));
2295 assert!(line.contains("\"rows\":40"));
2296 }
2297
2298 #[test]
2299 fn trace_record_frame_to_jsonl() {
2300 let r = TraceRecord::Frame {
2301 frame_idx: 3,
2302 ts_ns: 48_000_000,
2303 checksum: 0xDEADBEEF,
2304 checksum_chain: 0xCAFEBABE,
2305 };
2306 let line = r.to_jsonl();
2307 assert!(line.contains("\"event\":\"frame\""));
2308 assert!(line.contains("\"frame_idx\":3"));
2309 assert!(line.contains("\"frame_hash\":\"00000000deadbeef\""));
2310 assert!(line.contains("\"checksum_chain\":\"00000000cafebabe\""));
2311 }
2312
2313 #[test]
2314 fn trace_record_summary_to_jsonl() {
2315 let r = TraceRecord::Summary {
2316 total_frames: 10,
2317 final_checksum_chain: 0x1234567890ABCDEF,
2318 };
2319 let line = r.to_jsonl();
2320 assert!(line.contains("\"event\":\"trace_summary\""));
2321 assert!(line.contains("\"total_frames\":10"));
2322 assert!(line.contains("\"final_checksum_chain\":\"1234567890abcdef\""));
2323 }
2324
2325 #[test]
2328 fn jsonl_round_trip_full_session() {
2329 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 42);
2331 rec.init().unwrap();
2332 rec.push_event(1_000_000, key_event('+'));
2333 rec.push_event(2_000_000, key_event('+'));
2334 rec.step().unwrap();
2335 let trace = rec.finish();
2336
2337 let jsonl = trace.to_jsonl();
2339 assert!(!jsonl.is_empty());
2340
2341 let parsed = SessionTrace::from_jsonl(&jsonl).unwrap();
2343 assert_eq!(parsed.records.len(), trace.records.len());
2344 assert_eq!(parsed.frame_count(), trace.frame_count());
2345 assert_eq!(parsed.final_checksum_chain(), trace.final_checksum_chain());
2346 }
2347
2348 #[test]
2349 fn jsonl_round_trip_preserves_events() {
2350 let events = vec![
2351 key_event('+'),
2352 key_event('-'),
2353 Event::Key(KeyEvent {
2354 code: KeyCode::Enter,
2355 modifiers: Modifiers::CTRL | Modifiers::SHIFT,
2356 kind: KeyEventKind::Press,
2357 }),
2358 Event::Key(KeyEvent {
2359 code: KeyCode::F(12),
2360 modifiers: Modifiers::ALT,
2361 kind: KeyEventKind::Repeat,
2362 }),
2363 Event::Mouse(MouseEvent {
2364 kind: MouseEventKind::Down(MouseButton::Left),
2365 x: 10,
2366 y: 5,
2367 modifiers: Modifiers::empty(),
2368 }),
2369 Event::Mouse(MouseEvent {
2370 kind: MouseEventKind::ScrollDown,
2371 x: 0,
2372 y: 0,
2373 modifiers: Modifiers::CTRL,
2374 }),
2375 Event::Paste(PasteEvent::bracketed("hello world")),
2376 Event::Focus(true),
2377 Event::Focus(false),
2378 Event::Tick,
2379 ];
2380
2381 for (i, event) in events.iter().enumerate() {
2382 let record = TraceRecord::Input {
2383 ts_ns: i as u64 * 1_000_000,
2384 event: event.clone(),
2385 };
2386 let jsonl = record.to_jsonl();
2387 let parsed = SessionTrace::from_jsonl(&jsonl).unwrap();
2388 let parsed_record = &parsed.records[0];
2389 let TraceRecord::Input {
2390 event: parsed_event,
2391 ..
2392 } = parsed_record
2393 else {
2394 unreachable!("expected Input record for event {i}");
2395 };
2396
2397 assert_eq!(parsed_event, event, "event {i} round-trip failed: {jsonl}");
2398 }
2399 }
2400
2401 #[test]
2402 fn jsonl_round_trip_with_resize() {
2403 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2404 rec.init().unwrap();
2405 rec.resize(5_000_000, 40, 2);
2406 rec.step().unwrap();
2407 let trace = rec.finish();
2408
2409 let jsonl = trace.to_jsonl();
2410 let parsed = SessionTrace::from_jsonl(&jsonl).unwrap();
2411
2412 let result = replay(new_counter(0), &parsed).unwrap();
2414 assert!(
2415 result.ok(),
2416 "parsed trace replay failed: {:?}",
2417 result.first_mismatch
2418 );
2419 }
2420
2421 #[test]
2424 fn from_jsonl_empty_is_ok() {
2425 let trace = SessionTrace::from_jsonl("").unwrap();
2426 assert!(trace.records.is_empty());
2427 }
2428
2429 #[test]
2430 fn from_jsonl_unknown_event_fails() {
2431 let line = r#"{"schema_version":"golden-trace-v1","event":"unknown_type","ts_ns":0}"#;
2432 let result = SessionTrace::from_jsonl(line);
2433 assert!(result.is_err());
2434 assert!(result.unwrap_err().message.contains("unknown event type"));
2435 }
2436
2437 #[test]
2438 fn from_jsonl_missing_event_field_fails() {
2439 let line = r#"{"schema_version":"golden-trace-v1","ts_ns":0}"#;
2440 let result = SessionTrace::from_jsonl(line);
2441 assert!(result.is_err());
2442 }
2443
2444 #[test]
2445 fn from_jsonl_missing_schema_version_fails() {
2446 let line = r#"{"event":"tick","ts_ns":0}"#;
2447 let result = SessionTrace::from_jsonl(line);
2448 assert!(result.is_err());
2449 assert!(result.unwrap_err().message.contains("schema_version"));
2450 }
2451
2452 #[test]
2453 fn from_jsonl_schema_matrix_current_writer_version_passes() {
2454 let line = format!(
2455 r#"{{"schema_version":"{}","event":"tick","ts_ns":0}}"#,
2456 SCHEMA_VERSION
2457 );
2458 let trace = SessionTrace::from_jsonl(&line).expect("matching schema should parse");
2459 assert_eq!(trace.records, vec![TraceRecord::Tick { ts_ns: 0 }]);
2460 }
2461
2462 #[test]
2463 fn from_jsonl_schema_matrix_newer_writer_version_fails_with_migration_error() {
2464 let line = r#"{"schema_version":"golden-trace-v2","event":"tick","ts_ns":0}"#;
2465 let result = SessionTrace::from_jsonl(line);
2466 assert!(result.is_err());
2467 let message = result.unwrap_err().message;
2468 assert!(message.contains("unsupported schema_version"));
2469 assert!(message.contains("migration required"));
2470 }
2471
2472 #[cfg(feature = "tracing")]
2473 #[test]
2474 fn schema_incompatibility_emits_compat_span_and_error_log() {
2475 let capture = TraceCaptureLayer::default();
2476 let subscriber =
2477 tracing_subscriber::registry().with(capture.clone().with_filter(LevelFilter::TRACE));
2478 let _guard = tracing::subscriber::set_default(subscriber);
2479
2480 let line = r#"{"schema_version":"golden-trace-v2","event":"tick","ts_ns":0}"#;
2481 let err = SessionTrace::from_jsonl(line).expect_err("newer schema should fail");
2482 assert!(err.message.contains("migration required"));
2483
2484 let spans = capture.spans.lock().expect("span capture lock");
2485 assert!(
2486 spans.iter().any(|name| name == "trace.compat_check"),
2487 "expected trace.compat_check span, got {spans:?}"
2488 );
2489 drop(spans);
2490
2491 let events = capture.events.lock().expect("event capture lock");
2492 assert!(
2493 events
2494 .iter()
2495 .any(|event| event.contains("ERROR:trace schema version incompatible")),
2496 "expected incompatible schema ERROR log, got {events:?}"
2497 );
2498 }
2499
2500 #[test]
2501 fn from_jsonl_validated_surfaces_validation_error_type() {
2502 let jsonl = TraceRecord::Header {
2503 seed: 0,
2504 cols: 80,
2505 rows: 24,
2506 profile: "modern".to_string(),
2507 }
2508 .to_jsonl();
2509 let result = SessionTrace::from_jsonl_validated(&jsonl);
2510 assert!(matches!(
2511 result,
2512 Err(TraceLoadError::Validation(
2513 TraceValidationError::MissingSummary
2514 ))
2515 ));
2516 }
2517
2518 #[test]
2521 fn json_escape_round_trip() {
2522 let cases = [
2523 "hello",
2524 "with\"quotes",
2525 "back\\slash",
2526 "line\nbreak",
2527 "tab\there",
2528 ];
2529 for input in cases {
2530 let escaped = json_escape(input);
2531 let unescaped = json_unescape(&escaped);
2532 assert_eq!(unescaped, input, "round-trip failed for: {input:?}");
2533 }
2534 }
2535
2536 #[test]
2537 fn extract_str_basic() {
2538 let json = r#"{"name":"alice","age":30}"#;
2539 assert_eq!(extract_str(json, "name"), Some("alice"));
2540 }
2541
2542 #[test]
2543 fn extract_u64_basic() {
2544 let json = r#"{"count":42,"name":"test"}"#;
2545 assert_eq!(extract_u64(json, "count"), Some(42));
2546 }
2547
2548 #[test]
2549 fn extract_i64_basic() {
2550 let json = r#"{"dx":-12,"dy":7}"#;
2551 assert_eq!(extract_i64(json, "dx"), Some(-12));
2552 assert_eq!(extract_i64(json, "dy"), Some(7));
2553 }
2554
2555 #[test]
2556 fn extract_bool_basic() {
2557 let json = r#"{"enabled":true,"disabled":false}"#;
2558 assert_eq!(extract_bool(json, "enabled"), Some(true));
2559 assert_eq!(extract_bool(json, "disabled"), Some(false));
2560 }
2561
2562 #[test]
2563 fn extract_hex_u64_basic() {
2564 let json = r#"{"hash":"00000000deadbeef"}"#;
2565 assert_eq!(extract_hex_u64(json, "hash"), Some(0xDEADBEEF));
2566 }
2567
2568 #[test]
2569 fn from_jsonl_parses_frankenterm_key_schema() {
2570 let down = parse_single_input_event(
2571 r#"{"kind":"key","phase":"down","code":"F12","mods":5,"repeat":false}"#,
2572 );
2573 assert_eq!(
2574 down,
2575 Event::Key(KeyEvent {
2576 code: KeyCode::F(12),
2577 modifiers: Modifiers::SHIFT | Modifiers::CTRL,
2578 kind: KeyEventKind::Press,
2579 })
2580 );
2581
2582 let repeat = parse_single_input_event(
2583 r#"{"kind":"key","phase":"down","code":"a","mods":0,"repeat":true}"#,
2584 );
2585 assert_eq!(
2586 repeat,
2587 Event::Key(KeyEvent {
2588 code: KeyCode::Char('a'),
2589 modifiers: Modifiers::empty(),
2590 kind: KeyEventKind::Repeat,
2591 })
2592 );
2593
2594 let release = parse_single_input_event(
2595 r#"{"kind":"key","phase":"up","code":"Enter","mods":0,"repeat":false}"#,
2596 );
2597 assert_eq!(
2598 release,
2599 Event::Key(KeyEvent {
2600 code: KeyCode::Enter,
2601 modifiers: Modifiers::empty(),
2602 kind: KeyEventKind::Release,
2603 })
2604 );
2605 }
2606
2607 #[test]
2608 fn key_event_json_round_trip_unescapes_code() {
2609 let quote_key = Event::Key(KeyEvent {
2610 code: KeyCode::Char('"'),
2611 modifiers: Modifiers::empty(),
2612 kind: KeyEventKind::Press,
2613 });
2614 let quote_json = event_to_json("e_key);
2615 let parsed_quote = parse_event_json("e_json).expect("quote key should parse");
2616 assert_eq!(parsed_quote, quote_key);
2617
2618 let slash_key = Event::Key(KeyEvent {
2619 code: KeyCode::Char('\\'),
2620 modifiers: Modifiers::SHIFT,
2621 kind: KeyEventKind::Press,
2622 });
2623 let slash_json = event_to_json(&slash_key);
2624 let parsed_slash = parse_event_json(&slash_json).expect("slash key should parse");
2625 assert_eq!(parsed_slash, slash_key);
2626 }
2627
2628 #[test]
2629 fn from_jsonl_parses_frankenterm_mouse_and_wheel_schema() {
2630 let mouse = parse_single_input_event(
2631 r#"{"kind":"mouse","phase":"drag","button":2,"x":7,"y":9,"mods":3}"#,
2632 );
2633 assert_eq!(
2634 mouse,
2635 Event::Mouse(MouseEvent {
2636 kind: MouseEventKind::Drag(MouseButton::Right),
2637 x: 7,
2638 y: 9,
2639 modifiers: Modifiers::SHIFT | Modifiers::ALT,
2640 })
2641 );
2642
2643 let wheel =
2644 parse_single_input_event(r#"{"kind":"wheel","x":4,"y":6,"dx":0,"dy":-2,"mods":4}"#);
2645 assert_eq!(
2646 wheel,
2647 Event::Mouse(MouseEvent {
2648 kind: MouseEventKind::ScrollUp,
2649 x: 4,
2650 y: 6,
2651 modifiers: Modifiers::CTRL,
2652 })
2653 );
2654 }
2655
2656 #[test]
2657 fn from_jsonl_parses_frankenterm_paste_focus_and_composition_aliases() {
2658 let paste = parse_single_input_event(r#"{"kind":"paste","data":"hello\nworld"}"#);
2659 assert_eq!(paste, Event::Paste(PasteEvent::new("hello\nworld", true)));
2660
2661 let focus = parse_single_input_event(r#"{"kind":"focus","focused":false}"#);
2662 assert_eq!(focus, Event::Focus(false));
2663
2664 let composition_update =
2665 parse_single_input_event(r#"{"kind":"composition","phase":"update","data":"你"}"#);
2666 assert_eq!(
2667 composition_update,
2668 Event::Ime(ImeEvent::new(ImePhase::Update, "你"))
2669 );
2670
2671 let composition_end =
2672 parse_single_input_event(r#"{"kind":"composition","phase":"end","data":"你好"}"#);
2673 assert_eq!(
2674 composition_end,
2675 Event::Ime(ImeEvent::new(ImePhase::Commit, "你好"))
2676 );
2677 }
2678
2679 #[test]
2682 fn gate_trace_passes_on_correct_replay() {
2683 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2684 rec.init().unwrap();
2685 rec.push_event(1_000_000, key_event('+'));
2686 rec.step().unwrap();
2687 let trace = rec.finish();
2688
2689 let report = gate_trace(new_counter(0), &trace).unwrap();
2690 assert!(report.passed);
2691 assert_eq!(report.total_frames, 2);
2692 assert!(report.diff.is_none());
2693 assert!(report.format().starts_with("PASS"));
2694 }
2695
2696 #[test]
2697 fn gate_trace_fails_with_actionable_diff() {
2698 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2699 rec.init().unwrap();
2700 rec.push_event(1_000_000, key_event('+'));
2701 rec.push_event(2_000_000, key_event('+'));
2702 rec.step().unwrap();
2703 let trace = rec.finish();
2704
2705 let report = gate_trace(new_counter(5), &trace).unwrap();
2707 assert!(!report.passed);
2708 assert!(report.diff.is_some());
2709
2710 let diff = report.diff.as_ref().unwrap();
2711 assert_eq!(diff.frame_idx, 0); let formatted = report.format();
2714 assert!(formatted.starts_with("FAIL"));
2715 assert!(formatted.contains("frame 0"));
2716 }
2717
2718 #[test]
2719 fn gate_trace_diff_has_event_context() {
2720 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2721 rec.init().unwrap();
2722 rec.push_event(1_000_000, key_event('+'));
2723 rec.push_event(2_000_000, key_event('+'));
2724 rec.step().unwrap();
2725 rec.push_event(3_000_000, key_event('-'));
2726 rec.step().unwrap();
2727 let trace = rec.finish();
2728
2729 let mut tampered = trace.clone();
2731 for record in &mut tampered.records {
2732 if let TraceRecord::Frame {
2733 frame_idx,
2734 checksum,
2735 ..
2736 } = record
2737 && *frame_idx == 2
2738 {
2739 *checksum = 0xBAD;
2740 }
2741 }
2742
2743 let report = gate_trace(new_counter(0), &tampered).unwrap();
2744 assert!(!report.passed);
2745 let diff = report.diff.unwrap();
2746 assert_eq!(diff.frame_idx, 2);
2747 assert!(diff.event_idx > 0); }
2749
2750 #[test]
2753 fn jsonl_serialize_parse_replay_round_trip() {
2754 let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2756 rec.init().unwrap();
2757
2758 for i in 0..3 {
2759 rec.push_event(i * 16_000_000, key_event('+'));
2760 rec.step().unwrap();
2761 }
2762 let original_trace = rec.finish();
2763
2764 let jsonl = original_trace.to_jsonl();
2766
2767 let parsed_trace = SessionTrace::from_jsonl(&jsonl).unwrap();
2769
2770 let result = replay(new_counter(0), &parsed_trace).unwrap();
2772 assert!(
2773 result.ok(),
2774 "JSONL round-trip replay failed: {:?}",
2775 result.first_mismatch
2776 );
2777 assert_eq!(result.total_frames, original_trace.frame_count());
2778 assert_eq!(
2779 result.final_checksum_chain,
2780 original_trace.final_checksum_chain().unwrap()
2781 );
2782 }
2783
2784 #[test]
2787 fn trace_parse_error_display() {
2788 let e = TraceParseError {
2789 line: 5,
2790 message: "bad field".to_string(),
2791 };
2792 assert_eq!(e.to_string(), "line 5: bad field");
2793 }
2794}