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