Skip to main content

ftui_web/
session_record.rs

1#![forbid(unsafe_code)]
2
3//! Deterministic session recording and replay for WASM (bd-lff4p.3.7).
4//!
5//! Provides [`SessionRecorder`] for recording input events, time steps, and
6//! resize events during a WASM session, and [`replay`] for replaying them
7//! through a fresh model to verify that frame checksums match exactly.
8//!
9//! # Design
10//!
11//! Follows the golden-trace-v1 schema defined in
12//! `docs/spec/frankenterm-golden-trace-format.md`:
13//!
14//! - **Header**: seed, initial dimensions, capability profile.
15//! - **Input**: timestamped terminal events (key, mouse, paste, etc.).
16//! - **Resize**: terminal resize events.
17//! - **Tick**: explicit time advancement events.
18//! - **Frame**: frame checkpoints with FNV-1a checksums and chaining.
19//! - **Summary**: total frames and final checksum chain.
20//!
21//! # Determinism contract
22//!
23//! Given identical recorded inputs and the same model implementation, replay
24//! **must** produce identical frame checksums on the same build. This is
25//! guaranteed by:
26//!
27//! 1. Host-driven clock (no `Instant::now()` — time only advances via explicit
28//!    tick records).
29//! 2. Host-driven events (no polling — events are replayed from the trace).
30//! 3. Deterministic rendering (same model state → same buffer → same checksum).
31//!
32//! # Example
33//!
34//! ```ignore
35//! let mut recorder = SessionRecorder::new(MyModel::default(), 80, 24, /*seed=*/0);
36//! recorder.init().unwrap();
37//!
38//! recorder.push_event(0, key_event('+'));
39//! recorder.advance_time(16_000_000, Duration::from_millis(16));
40//! recorder.step().unwrap();
41//!
42//! let trace = recorder.finish();
43//! let result = replay(MyModel::default(), &trace).unwrap();
44//! assert!(result.ok());
45//! ```
46
47use 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
60/// Schema version for session traces.
61pub const SCHEMA_VERSION: &str = "golden-trace-v1";
62
63// FNV-1a constants — identical to ftui-runtime/src/render_trace.rs.
64const 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/// A single record in a session trace.
86#[derive(Debug, Clone, PartialEq)]
87pub enum TraceRecord {
88    /// Session header (must be first).
89    Header {
90        seed: u64,
91        cols: u16,
92        rows: u16,
93        profile: String,
94    },
95    /// An input event at a specific timestamp.
96    Input { ts_ns: u64, event: Event },
97    /// Terminal resize at a specific timestamp.
98    Resize { ts_ns: u64, cols: u16, rows: u16 },
99    /// Explicit time advancement.
100    Tick { ts_ns: u64 },
101    /// Frame checkpoint with checksum.
102    Frame {
103        frame_idx: u64,
104        ts_ns: u64,
105        checksum: u64,
106        checksum_chain: u64,
107    },
108    /// Trace summary (must be last).
109    Summary {
110        total_frames: u64,
111        final_checksum_chain: u64,
112    },
113}
114
115/// A complete recorded session trace.
116#[derive(Debug, Clone)]
117pub struct SessionTrace {
118    pub records: Vec<TraceRecord>,
119}
120
121impl SessionTrace {
122    /// Number of frame checkpoints in the trace.
123    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    /// Extract the final checksum chain from the summary record.
131    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    /// Validate structural invariants for a recorded trace.
142    ///
143    /// This checks:
144    /// - header exists and is the first record
145    /// - summary exists and is the last record
146    /// - frame indices are contiguous and start at zero
147    /// - summary totals/chains match frame records
148    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
283/// Records a WASM session for deterministic replay.
284///
285/// Wraps a [`StepProgram`] and intercepts all input operations, recording
286/// them as [`TraceRecord`]s. Frame checksums are computed after each render
287/// using the same FNV-1a algorithm as the render trace system.
288pub 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    /// Create a new recorder with the given model, initial size, and seed.
297    #[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    /// Initialize the model and record the first frame checkpoint.
315    pub fn init(&mut self) -> Result<(), WebBackendError> {
316        self.program.init()?;
317        self.record_frame();
318        Ok(())
319    }
320
321    /// Record an input event at the given timestamp (nanoseconds since start).
322    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    /// Record a resize at the given timestamp.
332    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    /// Record a time advancement (tick) at the given timestamp.
343    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    /// Process one step and record a frame checkpoint if rendered.
350    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    /// Finish recording and return the completed trace.
359    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    /// Access the underlying program.
375    pub fn program(&self) -> &StepProgram<M> {
376        &self.program
377    }
378
379    /// Mutably access the underlying program.
380    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/// Result of replaying a session trace.
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct ReplayResult {
403    /// Total frames replayed.
404    pub total_frames: u64,
405    /// Final checksum chain from replay.
406    pub final_checksum_chain: u64,
407    /// First frame where a checksum mismatch was detected, if any.
408    pub first_mismatch: Option<ReplayMismatch>,
409}
410
411impl ReplayResult {
412    /// Whether the replay produced identical checksums.
413    #[must_use]
414    pub fn ok(&self) -> bool {
415        self.first_mismatch.is_none()
416    }
417}
418
419/// Description of a checksum mismatch during replay.
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct ReplayMismatch {
422    /// Frame index where the mismatch occurred.
423    pub frame_idx: u64,
424    /// Expected checksum from the trace.
425    pub expected: u64,
426    /// Actual checksum from replay.
427    pub actual: u64,
428}
429
430/// Errors that can occur during replay.
431#[derive(Debug, Clone, PartialEq, Eq)]
432pub enum ReplayError {
433    /// The trace is missing a header record.
434    MissingHeader,
435    /// The trace violates structural invariants.
436    InvalidTrace(TraceValidationError),
437    /// A backend error occurred during replay.
438    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
459/// Replay a recorded session trace through a fresh model.
460///
461/// Feeds all recorded events, resizes, and ticks through a new
462/// [`StepProgram`], stepping only at frame boundaries (matching the
463/// original recording cadence). Compares frame checksums against the
464/// recorded values.
465///
466/// Returns [`ReplayResult`] with match/mismatch information.
467pub fn replay<M: ftui_runtime::program::Model>(
468    model: M,
469    trace: &SessionTrace,
470) -> Result<ReplayResult, ReplayError> {
471    // Extract header.
472    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    // Replay by iterating through trace records. Input/Resize/Tick records
490    // feed data into the program; Frame records trigger a step and checksum
491    // verification. This ensures event batching matches the original session.
492    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                // The init frame (frame_idx 0) was already rendered by init().
509                // Subsequent frames require a step() call.
510                if replay_frame_idx > 0 {
511                    program.step()?;
512                }
513
514                // Verify checksum.
515                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
540// ---- JSONL serialization / deserialization ----
541
542fn 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    /// Serialize this record as a golden-trace-v1 JSONL line.
689    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    /// Serialize the entire trace as a golden-trace-v1 JSONL string.
740    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    /// Parse a golden-trace-v1 JSONL string into a `SessionTrace`.
750    ///
751    /// Returns a parse error with the line number on failure.
752    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    /// Parse and validate a golden-trace-v1 JSONL payload.
766    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/// Error parsing a JSONL trace.
774#[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/// Typed validation failures for `SessionTrace`.
789#[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/// Combined load error for parse + validation.
866#[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
895// ---- Minimal JSON field extraction (no serde dependency) ----
896
897fn 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    // Find closing quote, handling escapes.
902    let mut i = 0;
903    let bytes = rest.as_bytes();
904    while i < bytes.len() {
905        if bytes[i] == b'\\' {
906            i += 2; // Skip escaped char.
907            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
1380// ---- Golden Gate API ----
1381
1382/// Validate a trace against a fresh model, returning a detailed report.
1383///
1384/// This is the primary entry point for CI checksum gates. It replays the
1385/// trace and produces a [`GateReport`] with pass/fail status and actionable
1386/// diff information on any mismatch.
1387pub 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        // Find the event context: count Input/Resize/Tick records before the failing frame.
1408        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/// Report from a golden trace gate validation.
1454#[derive(Debug, Clone)]
1455pub struct GateReport {
1456    /// Whether all frame checksums matched.
1457    pub passed: bool,
1458    /// Number of frames replayed.
1459    pub total_frames: u64,
1460    /// Number of frame checkpoints in the trace.
1461    pub expected_frames: u64,
1462    /// Final checksum chain from replay.
1463    pub final_checksum_chain: u64,
1464    /// Detailed diff information if there was a mismatch.
1465    pub diff: Option<GateDiff>,
1466}
1467
1468impl GateReport {
1469    /// Format the report as a human-readable string.
1470    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/// Detailed diff information for a checksum mismatch.
1491#[derive(Debug, Clone)]
1492pub struct GateDiff {
1493    /// Frame index where the mismatch occurred.
1494    pub frame_idx: u64,
1495    /// Number of input events processed before the failing frame.
1496    pub event_idx: u64,
1497    /// Description of the last event before the failing frame.
1498    pub last_event: String,
1499    /// Expected checksum from the trace.
1500    pub expected_checksum: u64,
1501    /// Actual checksum from replay.
1502    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    // ---- Test model (same as step_program tests) ----
1588
1589    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    // ---- FNV-1a hash tests ----
1679
1680    #[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    // ---- Recorder basic lifecycle ----
1694
1695    #[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); // header + frame + summary
1702
1703        // First record is header.
1704        assert!(matches!(
1705            &trace.records[0],
1706            TraceRecord::Header {
1707                seed: 42,
1708                cols: 80,
1709                rows: 24,
1710                ..
1711            }
1712        ));
1713
1714        // Last record is summary.
1715        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); // Non-trivial checksum.
1745        }
1746    }
1747
1748    // ---- Record and replay ----
1749
1750    #[test]
1751    fn record_replay_identical_checksums() {
1752        // Record a session.
1753        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); // init + 2 steps
1766
1767        // Replay with a fresh model.
1768        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        // Record with counter starting at 0.
1780        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1781        rec.init().unwrap();
1782        let trace = rec.finish();
1783
1784        // Replay with counter starting at 5 — different init state → different checksum.
1785        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        // Record with normal counter.
1793        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        // Replay with a model that starts at 1 instead of 0.
1803        let result = replay(new_counter(1), &trace).unwrap();
1804        assert!(!result.ok());
1805    }
1806
1807    // ---- Resize recording ----
1808
1809    #[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        // Verify resize record exists.
1820        assert!(trace.records.iter().any(|r| matches!(
1821            r,
1822            TraceRecord::Resize {
1823                cols: 40,
1824                rows: 2,
1825                ..
1826            }
1827        )));
1828
1829        // Replay should match.
1830        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    // ---- Multiple steps ----
1839
1840    #[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); // init + 5 steps
1852
1853        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    // ---- Quit during session ----
1863
1864    #[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        // init frame + no render after quit (quit stops before render).
1876        assert_eq!(trace.frame_count(), 1);
1877    }
1878
1879    // ---- Empty session ----
1880
1881    #[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); // Just the init frame.
1890    }
1891
1892    // ---- Trace accessors ----
1893
1894    #[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    // ---- Replay error cases ----
1914
1915    #[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    // ---- Determinism: same input → same trace ----
2058
2059    #[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        // All traces should have identical frame checksums.
2081        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    // ---- Mouse, paste, and focus events ----
2097
2098    #[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    // ---- Checksum chain integrity ----
2161
2162    #[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        // Verify chain: each chain = fnv1a64_pair(prev_chain, checksum).
2190        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        // Final chain in summary matches last frame chain.
2200        assert_eq!(trace.final_checksum_chain(), Some(chain2));
2201    }
2202
2203    // ---- Recorder program accessors ----
2204
2205    #[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    // ---- ReplayResult and ReplayError ----
2213
2214    #[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    // ---- JSONL serialization ----
2254
2255    #[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    // ---- JSONL round-trip ----
2326
2327    #[test]
2328    fn jsonl_round_trip_full_session() {
2329        // Record a session.
2330        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        // Serialize to JSONL.
2338        let jsonl = trace.to_jsonl();
2339        assert!(!jsonl.is_empty());
2340
2341        // Deserialize back.
2342        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        // Replay parsed trace.
2413        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    // ---- JSONL parsing errors ----
2422
2423    #[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    // ---- JSON helpers ----
2519
2520    #[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(&quote_key);
2615        let parsed_quote = parse_event_json(&quote_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    // ---- Golden Gate API ----
2680
2681    #[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        // Replay with different initial state.
2706        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); // First frame mismatch (init).
2712
2713        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        // Tamper with the trace: change a frame checksum.
2730        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); // Events were processed before frame 2.
2748    }
2749
2750    // ---- JSONL → replay integration ----
2751
2752    #[test]
2753    fn jsonl_serialize_parse_replay_round_trip() {
2754        // Full pipeline: record → JSONL → parse → replay → verify.
2755        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        // Serialize.
2765        let jsonl = original_trace.to_jsonl();
2766
2767        // Parse.
2768        let parsed_trace = SessionTrace::from_jsonl(&jsonl).unwrap();
2769
2770        // Replay parsed trace.
2771        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    // ---- TraceParseError ----
2785
2786    #[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}