Skip to main content

ftui_runtime/
render_trace.rs

1#![forbid(unsafe_code)]
2
3//! Render-trace recorder for deterministic replay (bd-3e1t.4.13).
4//!
5//! Emits JSONL records following the render-trace v1 schema in
6//! `docs/spec/state-machines.md`:
7//! - header (event="trace_header")
8//! - frame (event="frame")
9//! - summary (event="trace_summary")
10
11use std::fs::{OpenOptions, create_dir_all};
12use std::io::{self, BufWriter, Write};
13use std::path::PathBuf;
14use web_time::{Instant, SystemTime, UNIX_EPOCH};
15
16use ftui_core::terminal_capabilities::TerminalCapabilities;
17use ftui_render::buffer::Buffer;
18use ftui_render::cell::{Cell, CellAttrs, CellContent};
19use ftui_render::diff::BufferDiff;
20use ftui_render::grapheme_pool::GraphemePool;
21
22use crate::conformal_predictor::ConformalConfig;
23use crate::resize_coalescer::CoalescerConfig;
24use crate::terminal_writer::RuntimeDiffConfig;
25
26/// Configuration for render-trace recording.
27#[derive(Debug, Clone)]
28pub struct RenderTraceConfig {
29    /// Enable render-trace recording.
30    pub enabled: bool,
31    /// Output JSONL path (trace.jsonl).
32    pub output_path: PathBuf,
33    /// Optional run identifier override.
34    pub run_id: Option<String>,
35    /// Optional deterministic seed (or null).
36    pub seed: Option<u64>,
37    /// Optional test module label (or null).
38    pub test_module: Option<String>,
39    /// Flush after every JSONL line.
40    pub flush_on_write: bool,
41    /// Include start_ts_ms in header (non-deterministic if true).
42    pub include_start_ts_ms: bool,
43}
44
45impl Default for RenderTraceConfig {
46    fn default() -> Self {
47        Self {
48            enabled: false,
49            output_path: PathBuf::from("trace.jsonl"),
50            run_id: None,
51            seed: None,
52            test_module: None,
53            flush_on_write: true,
54            include_start_ts_ms: false,
55        }
56    }
57}
58
59impl RenderTraceConfig {
60    /// Enable render-trace recording to the given path.
61    #[must_use]
62    pub fn enabled_file(path: impl Into<PathBuf>) -> Self {
63        Self {
64            enabled: true,
65            output_path: path.into(),
66            ..Default::default()
67        }
68    }
69
70    /// Set a run identifier.
71    #[must_use]
72    pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
73        self.run_id = Some(run_id.into());
74        self
75    }
76
77    /// Set a deterministic seed.
78    #[must_use]
79    pub fn with_seed(mut self, seed: u64) -> Self {
80        self.seed = Some(seed);
81        self
82    }
83
84    /// Set a test module label.
85    #[must_use]
86    pub fn with_test_module(mut self, test_module: impl Into<String>) -> Self {
87        self.test_module = Some(test_module.into());
88        self
89    }
90
91    /// Toggle flush-on-write.
92    #[must_use]
93    pub fn with_flush_on_write(mut self, enabled: bool) -> Self {
94        self.flush_on_write = enabled;
95        self
96    }
97
98    /// Include `start_ts_ms` in header (non-deterministic).
99    #[must_use]
100    pub fn with_start_ts_ms(mut self, enabled: bool) -> Self {
101        self.include_start_ts_ms = enabled;
102        self
103    }
104}
105
106/// Context used to build a render-trace header.
107#[derive(Debug, Clone)]
108pub struct RenderTraceContext<'a> {
109    pub capabilities: &'a TerminalCapabilities,
110    pub diff_config: RuntimeDiffConfig,
111    pub resize_config: CoalescerConfig,
112    pub conformal_config: Option<ConformalConfig>,
113}
114
115/// Render-trace recorder.
116pub struct RenderTraceRecorder {
117    writer: BufWriter<std::fs::File>,
118    flush_on_write: bool,
119    frame_idx: u64,
120    checksum_chain: u64,
121    total_frames: u64,
122    finished: bool,
123    payload_dir: Option<PayloadDir>,
124}
125
126#[derive(Debug, Clone)]
127struct PayloadDir {
128    abs: PathBuf,
129    rel: String,
130}
131
132/// Payload kind for render-trace frames.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum RenderTracePayloadKind {
135    DiffRunsV1,
136    FullBufferV1,
137}
138
139impl RenderTracePayloadKind {
140    pub const fn as_str(self) -> &'static str {
141        match self {
142            Self::DiffRunsV1 => "diff_runs_v1",
143            Self::FullBufferV1 => "full_buffer_v1",
144        }
145    }
146}
147
148/// Render-trace payload bytes with its kind.
149#[derive(Debug, Clone)]
150pub struct RenderTracePayload {
151    pub kind: RenderTracePayloadKind,
152    pub bytes: Vec<u8>,
153}
154
155/// Payload metadata written to disk.
156#[derive(Debug, Clone)]
157pub struct RenderTracePayloadInfo {
158    pub kind: &'static str,
159    pub path: String,
160}
161
162impl RenderTraceRecorder {
163    /// Build a recorder from config. Returns `Ok(None)` when disabled.
164    pub fn from_config(
165        config: &RenderTraceConfig,
166        context: RenderTraceContext<'_>,
167    ) -> io::Result<Option<Self>> {
168        if !config.enabled {
169            return Ok(None);
170        }
171
172        let base_dir = config
173            .output_path
174            .parent()
175            .map(PathBuf::from)
176            .unwrap_or_else(|| PathBuf::from("."));
177        let stem = config
178            .output_path
179            .file_stem()
180            .and_then(|s| s.to_str())
181            .unwrap_or("trace");
182        let payload_dir_name = format!("{stem}_payloads");
183        let payload_dir_abs = base_dir.join(&payload_dir_name);
184        create_dir_all(&payload_dir_abs)?;
185
186        let file = OpenOptions::new()
187            .create(true)
188            .write(true)
189            .truncate(true)
190            .open(&config.output_path)?;
191        let mut recorder = Self {
192            writer: BufWriter::new(file),
193            flush_on_write: config.flush_on_write,
194            frame_idx: 0,
195            checksum_chain: 0,
196            total_frames: 0,
197            finished: false,
198            payload_dir: Some(PayloadDir {
199                abs: payload_dir_abs,
200                rel: payload_dir_name,
201            }),
202        };
203
204        let run_id = config
205            .run_id
206            .clone()
207            .unwrap_or_else(default_render_trace_run_id);
208        let env = RenderTraceEnv::new(config.test_module.clone());
209        let caps = RenderTraceCapabilities::from_caps(context.capabilities);
210        let policies = RenderTracePolicies::from_context(&context);
211        let start_ts_ms = if config.include_start_ts_ms {
212            Some(now_ms())
213        } else {
214            None
215        };
216        let header = RenderTraceHeader {
217            run_id,
218            seed: config.seed,
219            env,
220            capabilities: caps,
221            policies,
222            start_ts_ms,
223        };
224        recorder.write_jsonl(&header.to_jsonl())?;
225        Ok(Some(recorder))
226    }
227
228    /// Write a payload blob to the payload directory and return metadata.
229    pub fn write_payload(
230        &mut self,
231        payload: &RenderTracePayload,
232    ) -> io::Result<RenderTracePayloadInfo> {
233        let Some(dir) = &self.payload_dir else {
234            return Err(io::Error::other(
235                "render-trace payload directory unavailable",
236            ));
237        };
238        let file_name = format!("frame_{:06}_{}.bin", self.frame_idx, payload.kind.as_str());
239        let abs_path = dir.abs.join(&file_name);
240        let mut file = OpenOptions::new()
241            .create(true)
242            .write(true)
243            .truncate(true)
244            .open(&abs_path)?;
245        file.write_all(&payload.bytes)?;
246        if self.flush_on_write {
247            file.flush()?;
248        }
249        Ok(RenderTracePayloadInfo {
250            kind: payload.kind.as_str(),
251            path: format!("{}/{}", dir.rel, file_name),
252        })
253    }
254
255    /// Record a frame.
256    pub fn record_frame(
257        &mut self,
258        mut frame: RenderTraceFrame<'_>,
259        buffer: &Buffer,
260        pool: &GraphemePool,
261    ) -> io::Result<()> {
262        let trace_start = Instant::now();
263        let checksum = checksum_buffer(buffer, pool);
264        let checksum_chain = fnv1a64_pair(self.checksum_chain, checksum);
265        frame.trace_us = Some(trace_start.elapsed().as_micros() as u64);
266
267        let line = frame.to_jsonl(self.frame_idx, checksum, checksum_chain);
268        self.write_jsonl(&line)?;
269
270        self.frame_idx = self.frame_idx.saturating_add(1);
271        self.checksum_chain = checksum_chain;
272        self.total_frames = self.total_frames.saturating_add(1);
273        Ok(())
274    }
275
276    /// Finish recording and write summary.
277    pub fn finish(&mut self, elapsed_ms: Option<u64>) -> io::Result<()> {
278        if self.finished {
279            return Ok(());
280        }
281        let summary = RenderTraceSummary {
282            total_frames: self.total_frames,
283            final_checksum_chain: self.checksum_chain,
284            elapsed_ms,
285        };
286        self.write_jsonl(&summary.to_jsonl())?;
287        self.finished = true;
288        Ok(())
289    }
290
291    fn write_jsonl(&mut self, line: &str) -> io::Result<()> {
292        self.writer.write_all(line.as_bytes())?;
293        self.writer.write_all(b"\n")?;
294        if self.flush_on_write {
295            self.writer.flush()?;
296        }
297        Ok(())
298    }
299}
300
301/// Render-trace header record.
302#[derive(Debug, Clone)]
303struct RenderTraceHeader {
304    run_id: String,
305    seed: Option<u64>,
306    env: RenderTraceEnv,
307    capabilities: RenderTraceCapabilities,
308    policies: RenderTracePolicies,
309    start_ts_ms: Option<u64>,
310}
311
312impl RenderTraceHeader {
313    fn to_jsonl(&self) -> String {
314        let seed = opt_u64(self.seed);
315        let start_ts = opt_u64(self.start_ts_ms);
316        format!(
317            concat!(
318                r#"{{"event":"trace_header","schema_version":"render-trace-v1","#,
319                r#""run_id":"{}","seed":{},"env":{},"capabilities":{},"policies":{},"start_ts_ms":{}}}"#
320            ),
321            json_escape(&self.run_id),
322            seed,
323            self.env.to_json(),
324            self.capabilities.to_json(),
325            self.policies.to_json(),
326            start_ts
327        )
328    }
329}
330
331/// Render-trace frame record.
332#[derive(Debug, Clone)]
333pub struct RenderTraceFrame<'a> {
334    pub cols: u16,
335    pub rows: u16,
336    pub mode: &'a str,
337    pub ui_height: u16,
338    pub ui_anchor: &'a str,
339    pub diff_strategy: &'a str,
340    pub diff_cells: usize,
341    pub diff_runs: usize,
342    pub present_bytes: u64,
343    pub render_us: Option<u64>,
344    pub present_us: Option<u64>,
345    pub payload_kind: &'a str,
346    pub payload_path: Option<&'a str>,
347    pub trace_us: Option<u64>,
348}
349
350impl RenderTraceFrame<'_> {
351    fn to_jsonl(&self, frame_idx: u64, checksum: u64, checksum_chain: u64) -> String {
352        let render_us = opt_u64(self.render_us);
353        let present_us = opt_u64(self.present_us);
354        let payload_path = opt_str(self.payload_path);
355        let trace_us = opt_u64(self.trace_us);
356        format!(
357            concat!(
358                r#"{{"event":"frame","frame_idx":{},"cols":{},"rows":{},"mode":"{}","#,
359                r#""ui_height":{},"ui_anchor":"{}","diff_strategy":"{}","diff_cells":{},"diff_runs":{},"present_bytes":{},"render_us":{},"present_us":{},"checksum":"{:016x}","checksum_chain":"{:016x}","payload_kind":"{}","payload_path":{},"trace_us":{}}}"#
360            ),
361            frame_idx,
362            self.cols,
363            self.rows,
364            json_escape(self.mode),
365            self.ui_height,
366            json_escape(self.ui_anchor),
367            json_escape(self.diff_strategy),
368            self.diff_cells,
369            self.diff_runs,
370            self.present_bytes,
371            render_us,
372            present_us,
373            checksum,
374            checksum_chain,
375            json_escape(self.payload_kind),
376            payload_path,
377            trace_us
378        )
379    }
380}
381
382/// Render-trace summary record.
383#[derive(Debug, Clone)]
384struct RenderTraceSummary {
385    total_frames: u64,
386    final_checksum_chain: u64,
387    elapsed_ms: Option<u64>,
388}
389
390impl RenderTraceSummary {
391    fn to_jsonl(&self) -> String {
392        let elapsed_ms = opt_u64(self.elapsed_ms);
393        format!(
394            r#"{{"event":"trace_summary","total_frames":{},"final_checksum_chain":"{:016x}","elapsed_ms":{}}}"#,
395            self.total_frames, self.final_checksum_chain, elapsed_ms
396        )
397    }
398}
399
400#[derive(Debug, Clone)]
401struct RenderTraceEnv {
402    os: String,
403    arch: String,
404    test_module: Option<String>,
405}
406
407impl RenderTraceEnv {
408    fn new(test_module: Option<String>) -> Self {
409        Self {
410            os: std::env::consts::OS.to_string(),
411            arch: std::env::consts::ARCH.to_string(),
412            test_module,
413        }
414    }
415
416    fn to_json(&self) -> String {
417        format!(
418            r#"{{"os":"{}","arch":"{}","test_module":{}}}"#,
419            json_escape(&self.os),
420            json_escape(&self.arch),
421            opt_str(self.test_module.as_deref())
422        )
423    }
424}
425
426#[derive(Debug, Clone)]
427struct RenderTraceCapabilities {
428    profile: String,
429    true_color: bool,
430    colors_256: bool,
431    sync_output: bool,
432    osc8_hyperlinks: bool,
433    scroll_region: bool,
434    in_tmux: bool,
435    in_screen: bool,
436    in_zellij: bool,
437    kitty_keyboard: bool,
438    focus_events: bool,
439    bracketed_paste: bool,
440    mouse_sgr: bool,
441    osc52_clipboard: bool,
442}
443
444impl RenderTraceCapabilities {
445    fn from_caps(caps: &TerminalCapabilities) -> Self {
446        Self {
447            profile: caps.profile().as_str().to_string(),
448            true_color: caps.true_color,
449            colors_256: caps.colors_256,
450            sync_output: caps.sync_output,
451            osc8_hyperlinks: caps.osc8_hyperlinks,
452            scroll_region: caps.scroll_region,
453            in_tmux: caps.in_tmux,
454            in_screen: caps.in_screen,
455            in_zellij: caps.in_zellij,
456            kitty_keyboard: caps.kitty_keyboard,
457            focus_events: caps.focus_events,
458            bracketed_paste: caps.bracketed_paste,
459            mouse_sgr: caps.mouse_sgr,
460            osc52_clipboard: caps.osc52_clipboard,
461        }
462    }
463
464    fn to_json(&self) -> String {
465        format!(
466            concat!(
467                r#"{{"profile":"{}","true_color":{},"colors_256":{},"sync_output":{},"osc8_hyperlinks":{},"scroll_region":{},"in_tmux":{},"in_screen":{},"in_zellij":{},"kitty_keyboard":{},"focus_events":{},"bracketed_paste":{},"mouse_sgr":{},"osc52_clipboard":{}}}"#
468            ),
469            json_escape(&self.profile),
470            self.true_color,
471            self.colors_256,
472            self.sync_output,
473            self.osc8_hyperlinks,
474            self.scroll_region,
475            self.in_tmux,
476            self.in_screen,
477            self.in_zellij,
478            self.kitty_keyboard,
479            self.focus_events,
480            self.bracketed_paste,
481            self.mouse_sgr,
482            self.osc52_clipboard
483        )
484    }
485}
486
487#[derive(Debug, Clone)]
488struct RenderTracePolicies {
489    diff_bayesian: bool,
490    diff_dirty_rows: bool,
491    diff_dirty_spans: bool,
492    diff_guard_band: u16,
493    diff_merge_gap: u16,
494    bocpd_enabled: bool,
495    steady_delay_ms: u64,
496    burst_delay_ms: u64,
497    conformal_enabled: bool,
498    conformal_alpha: Option<f64>,
499    conformal_min_samples: Option<usize>,
500    conformal_window_size: Option<usize>,
501}
502
503impl RenderTracePolicies {
504    fn from_context(context: &RenderTraceContext) -> Self {
505        let diff = &context.diff_config;
506        let span = diff.dirty_span_config;
507        let resize = &context.resize_config;
508        let conformal = context.conformal_config.as_ref();
509        Self {
510            diff_bayesian: diff.bayesian_enabled,
511            diff_dirty_rows: diff.dirty_rows_enabled,
512            diff_dirty_spans: span.enabled,
513            diff_guard_band: span.guard_band,
514            diff_merge_gap: span.merge_gap,
515            bocpd_enabled: resize.enable_bocpd,
516            steady_delay_ms: resize.steady_delay_ms,
517            burst_delay_ms: resize.burst_delay_ms,
518            conformal_enabled: conformal.is_some(),
519            conformal_alpha: conformal.map(|c| c.alpha),
520            conformal_min_samples: conformal.map(|c| c.min_samples),
521            conformal_window_size: conformal.map(|c| c.window_size),
522        }
523    }
524
525    fn to_json(&self) -> String {
526        use std::fmt::Write as _;
527
528        let mut out = String::with_capacity(256);
529        out.push('{');
530        out.push_str("\"diff\":{");
531        let _ = write!(
532            out,
533            "\"bayesian\":{},\"dirty_rows\":{},\"dirty_spans\":{},\"guard_band\":{},\"merge_gap\":{}",
534            self.diff_bayesian,
535            self.diff_dirty_rows,
536            self.diff_dirty_spans,
537            self.diff_guard_band,
538            self.diff_merge_gap
539        );
540        out.push('}');
541        out.push(',');
542        out.push_str("\"bocpd\":{");
543        let _ = write!(
544            out,
545            "\"enabled\":{},\"steady_delay_ms\":{},\"burst_delay_ms\":{}",
546            self.bocpd_enabled, self.steady_delay_ms, self.burst_delay_ms
547        );
548        out.push('}');
549        out.push(',');
550        out.push_str("\"conformal\":{");
551        let _ = write!(
552            out,
553            "\"enabled\":{},\"alpha\":{},\"min_samples\":{},\"window_size\":{}",
554            self.conformal_enabled,
555            opt_f64(self.conformal_alpha),
556            opt_usize(self.conformal_min_samples),
557            opt_usize(self.conformal_window_size)
558        );
559        out.push('}');
560        out.push('}');
561        out
562    }
563}
564
565/// Deterministic FNV-1a checksum of a buffer grid.
566#[must_use]
567pub fn checksum_buffer(buffer: &Buffer, pool: &GraphemePool) -> u64 {
568    let width = buffer.width();
569    let height = buffer.height();
570
571    let mut hash = FNV_OFFSET_BASIS;
572    for y in 0..height {
573        for x in 0..width {
574            let cell = buffer.get_unchecked(x, y);
575            match cell.content {
576                CellContent::EMPTY => {
577                    hash = fnv1a64_byte(hash, 0u8);
578                    hash = fnv1a64_u16(hash, 0);
579                }
580                CellContent::CONTINUATION => {
581                    hash = fnv1a64_byte(hash, 3u8);
582                    hash = fnv1a64_u16(hash, 0);
583                }
584                content => {
585                    if let Some(ch) = content.as_char() {
586                        hash = fnv1a64_byte(hash, 1u8);
587                        let mut buf = [0u8; 4];
588                        let encoded = ch.encode_utf8(&mut buf);
589                        let bytes = encoded.as_bytes();
590                        let len = bytes.len().min(u16::MAX as usize) as u16;
591                        hash = fnv1a64_u16(hash, len);
592                        hash = fnv1a64_bytes(hash, &bytes[..len as usize]);
593                    } else if let Some(gid) = content.grapheme_id() {
594                        hash = fnv1a64_byte(hash, 2u8);
595                        let text = pool.get(gid).unwrap_or("");
596                        let bytes = text.as_bytes();
597                        let len = bytes.len().min(u16::MAX as usize) as u16;
598                        hash = fnv1a64_u16(hash, len);
599                        hash = fnv1a64_bytes(hash, &bytes[..len as usize]);
600                    } else {
601                        hash = fnv1a64_byte(hash, 0u8);
602                        hash = fnv1a64_u16(hash, 0);
603                    }
604                }
605            }
606
607            hash = fnv1a64_u32(hash, cell.fg.0);
608            hash = fnv1a64_u32(hash, cell.bg.0);
609            let attrs = pack_attrs(cell.attrs);
610            hash = fnv1a64_u32(hash, attrs);
611        }
612    }
613    hash
614}
615
616/// Encode a buffer into a full-buffer payload.
617#[must_use]
618pub fn build_full_buffer_payload(buffer: &Buffer, pool: &GraphemePool) -> RenderTracePayload {
619    let width = buffer.width();
620    let height = buffer.height();
621    let mut bytes = Vec::with_capacity(4 + (width as usize * height as usize * 16));
622    bytes.extend_from_slice(&width.to_le_bytes());
623    bytes.extend_from_slice(&height.to_le_bytes());
624    for y in 0..height {
625        for x in 0..width {
626            let cell = buffer.get_unchecked(x, y);
627            push_cell_bytes(&mut bytes, cell, pool);
628        }
629    }
630    RenderTracePayload {
631        kind: RenderTracePayloadKind::FullBufferV1,
632        bytes,
633    }
634}
635
636/// Encode diff runs into a payload.
637#[must_use]
638pub fn build_diff_runs_payload(
639    buffer: &Buffer,
640    diff: &BufferDiff,
641    pool: &GraphemePool,
642) -> RenderTracePayload {
643    let width = buffer.width();
644    let height = buffer.height();
645    let runs = diff.runs();
646    let mut bytes = Vec::with_capacity(12 + runs.len() * 24);
647    bytes.extend_from_slice(&width.to_le_bytes());
648    bytes.extend_from_slice(&height.to_le_bytes());
649    let run_count = runs.len() as u32;
650    bytes.extend_from_slice(&run_count.to_le_bytes());
651    for run in runs {
652        bytes.extend_from_slice(&run.y.to_le_bytes());
653        bytes.extend_from_slice(&run.x0.to_le_bytes());
654        bytes.extend_from_slice(&run.x1.to_le_bytes());
655        for x in run.x0..=run.x1 {
656            let cell = buffer.get_unchecked(x, run.y);
657            push_cell_bytes(&mut bytes, cell, pool);
658        }
659    }
660    RenderTracePayload {
661        kind: RenderTracePayloadKind::DiffRunsV1,
662        bytes,
663    }
664}
665
666fn pack_attrs(attrs: CellAttrs) -> u32 {
667    let flags = attrs.flags().bits() as u32;
668    let link = attrs.link_id() & 0x00FF_FFFF;
669    (flags << 24) | link
670}
671
672fn push_cell_bytes(out: &mut Vec<u8>, cell: &Cell, pool: &GraphemePool) {
673    match cell.content {
674        CellContent::EMPTY => {
675            out.push(0u8);
676        }
677        CellContent::CONTINUATION => {
678            out.push(3u8);
679        }
680        content => {
681            if let Some(ch) = content.as_char() {
682                out.push(1u8);
683                out.extend_from_slice(&(ch as u32).to_le_bytes());
684            } else if let Some(gid) = content.grapheme_id() {
685                out.push(2u8);
686                let text = pool.get(gid).unwrap_or("");
687                let bytes = text.as_bytes();
688                let len = bytes.len().min(u16::MAX as usize) as u16;
689                out.extend_from_slice(&len.to_le_bytes());
690                out.extend_from_slice(&bytes[..len as usize]);
691            } else {
692                out.push(0u8);
693            }
694        }
695    }
696    out.extend_from_slice(&cell.fg.0.to_le_bytes());
697    out.extend_from_slice(&cell.bg.0.to_le_bytes());
698    let attrs = pack_attrs(cell.attrs);
699    out.extend_from_slice(&attrs.to_le_bytes());
700}
701
702const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
703const FNV_PRIME: u64 = 0x100000001b3;
704
705fn fnv1a64_bytes(mut hash: u64, bytes: &[u8]) -> u64 {
706    let mut i = 0;
707    let len = bytes.len();
708    while i + 8 <= len {
709        hash ^= bytes[i] as u64;
710        hash = hash.wrapping_mul(FNV_PRIME);
711        hash ^= bytes[i + 1] as u64;
712        hash = hash.wrapping_mul(FNV_PRIME);
713        hash ^= bytes[i + 2] as u64;
714        hash = hash.wrapping_mul(FNV_PRIME);
715        hash ^= bytes[i + 3] as u64;
716        hash = hash.wrapping_mul(FNV_PRIME);
717        hash ^= bytes[i + 4] as u64;
718        hash = hash.wrapping_mul(FNV_PRIME);
719        hash ^= bytes[i + 5] as u64;
720        hash = hash.wrapping_mul(FNV_PRIME);
721        hash ^= bytes[i + 6] as u64;
722        hash = hash.wrapping_mul(FNV_PRIME);
723        hash ^= bytes[i + 7] as u64;
724        hash = hash.wrapping_mul(FNV_PRIME);
725        i += 8;
726    }
727    for &b in &bytes[i..] {
728        hash ^= b as u64;
729        hash = hash.wrapping_mul(FNV_PRIME);
730    }
731    hash
732}
733
734fn fnv1a64_byte(hash: u64, b: u8) -> u64 {
735    let mut hash = hash ^ (b as u64);
736    hash = hash.wrapping_mul(FNV_PRIME);
737    hash
738}
739
740fn fnv1a64_u16(hash: u64, v: u16) -> u64 {
741    fnv1a64_bytes(hash, &v.to_le_bytes())
742}
743
744fn fnv1a64_u32(hash: u64, v: u32) -> u64 {
745    fnv1a64_bytes(hash, &v.to_le_bytes())
746}
747
748fn fnv1a64_pair(prev: u64, next: u64) -> u64 {
749    let mut hash = FNV_OFFSET_BASIS;
750    hash = fnv1a64_u64(hash, prev);
751    fnv1a64_u64(hash, next)
752}
753
754fn fnv1a64_u64(hash: u64, v: u64) -> u64 {
755    fnv1a64_bytes(hash, &v.to_le_bytes())
756}
757
758fn default_render_trace_run_id() -> String {
759    format!("render-trace-{}", std::process::id())
760}
761
762fn now_ms() -> u64 {
763    SystemTime::now()
764        .duration_since(UNIX_EPOCH)
765        .map(|d| d.as_millis() as u64)
766        .unwrap_or(0)
767}
768
769fn opt_u64(v: Option<u64>) -> String {
770    v.map_or_else(|| "null".to_string(), |v| v.to_string())
771}
772
773fn opt_usize(v: Option<usize>) -> String {
774    v.map_or_else(|| "null".to_string(), |v| v.to_string())
775}
776
777fn opt_f64(v: Option<f64>) -> String {
778    v.map_or_else(|| "null".to_string(), |v| format!("{v:.6}"))
779}
780
781fn opt_str(v: Option<&str>) -> String {
782    v.map_or_else(|| "null".to_string(), |s| format!("\"{}\"", json_escape(s)))
783}
784
785fn json_escape(input: &str) -> String {
786    let mut out = String::with_capacity(input.len() + 8);
787    for ch in input.chars() {
788        match ch {
789            '"' => out.push_str("\\\""),
790            '\\' => out.push_str("\\\\"),
791            '\n' => out.push_str("\\n"),
792            '\r' => out.push_str("\\r"),
793            '\t' => out.push_str("\\t"),
794            c if c.is_control() => {
795                use std::fmt::Write as _;
796                let _ = write!(out, "\\u{:04x}", c as u32);
797            }
798            c => out.push(c),
799        }
800    }
801    out
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use ftui_render::buffer::Buffer;
808    use ftui_render::cell::Cell;
809
810    fn temp_trace_path(label: &str) -> PathBuf {
811        static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
812        let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
813        let mut path = std::env::temp_dir();
814        path.push(format!(
815            "ftui_render_trace_{}_{}_{}.jsonl",
816            label,
817            std::process::id(),
818            id
819        ));
820        path
821    }
822
823    #[test]
824    fn checksum_is_deterministic() {
825        let mut buffer = Buffer::new(4, 2);
826        buffer.set(0, 0, Cell::from_char('A'));
827        buffer.set(1, 0, Cell::from_char('B'));
828        let pool = GraphemePool::new();
829        let a = checksum_buffer(&buffer, &pool);
830        let b = checksum_buffer(&buffer, &pool);
831        assert_eq!(a, b);
832    }
833
834    #[test]
835    fn recorder_writes_header_frame_summary() {
836        let path = temp_trace_path("basic");
837        let config = RenderTraceConfig::enabled_file(&path);
838        let caps = TerminalCapabilities::default();
839        let context = RenderTraceContext {
840            capabilities: &caps,
841            diff_config: RuntimeDiffConfig::default(),
842            resize_config: CoalescerConfig::default(),
843            conformal_config: None,
844        };
845        let mut recorder = RenderTraceRecorder::from_config(&config, context)
846            .expect("config")
847            .expect("enabled");
848
849        let buffer = Buffer::new(2, 2);
850        let pool = GraphemePool::new();
851        let frame = RenderTraceFrame {
852            cols: 2,
853            rows: 2,
854            mode: "inline",
855            ui_height: 2,
856            ui_anchor: "bottom",
857            diff_strategy: "full",
858            diff_cells: 4,
859            diff_runs: 2,
860            present_bytes: 16,
861            render_us: None,
862            present_us: Some(10),
863            payload_kind: "none",
864            payload_path: None,
865            trace_us: Some(2),
866        };
867
868        recorder.record_frame(frame, &buffer, &pool).expect("frame");
869        recorder.finish(Some(42)).expect("finish");
870
871        let text = std::fs::read_to_string(path).expect("read");
872        assert!(text.contains("\"event\":\"trace_header\""));
873        assert!(text.contains("\"event\":\"frame\""));
874        assert!(text.contains("\"event\":\"trace_summary\""));
875    }
876
877    // --- JSON helper tests ---
878
879    #[test]
880    fn json_escape_basic() {
881        assert_eq!(json_escape("hello"), "hello");
882        assert_eq!(json_escape(""), "");
883    }
884
885    #[test]
886    fn json_escape_special_chars() {
887        assert_eq!(json_escape(r#"say "hi""#), r#"say \"hi\""#);
888        assert_eq!(json_escape("back\\slash"), "back\\\\slash");
889        assert_eq!(json_escape("line\nbreak"), "line\\nbreak");
890        assert_eq!(json_escape("tab\there"), "tab\\there");
891        assert_eq!(json_escape("cr\rhere"), "cr\\rhere");
892    }
893
894    #[test]
895    fn json_escape_control_chars() {
896        // Control char \x01 should be unicode-escaped
897        let input = "a\x01b";
898        let escaped = json_escape(input);
899        assert_eq!(escaped, "a\\u0001b");
900    }
901
902    #[test]
903    fn opt_u64_some_none() {
904        assert_eq!(opt_u64(Some(42)), "42");
905        assert_eq!(opt_u64(None), "null");
906        assert_eq!(opt_u64(Some(0)), "0");
907    }
908
909    #[test]
910    fn opt_usize_some_none() {
911        assert_eq!(opt_usize(Some(100)), "100");
912        assert_eq!(opt_usize(None), "null");
913    }
914
915    #[test]
916    fn opt_f64_some_none() {
917        assert_eq!(opt_f64(None), "null");
918        let s = opt_f64(Some(0.5));
919        assert!(s.starts_with("0.5"), "got: {s}");
920    }
921
922    #[test]
923    fn opt_str_some_none() {
924        assert_eq!(opt_str(None), "null");
925        assert_eq!(opt_str(Some("test")), "\"test\"");
926        assert_eq!(opt_str(Some("with\"quote")), "\"with\\\"quote\"");
927    }
928
929    // --- FNV hash tests ---
930
931    #[test]
932    fn fnv1a64_byte_deterministic() {
933        let a = fnv1a64_byte(FNV_OFFSET_BASIS, 0x42);
934        let b = fnv1a64_byte(FNV_OFFSET_BASIS, 0x42);
935        assert_eq!(a, b);
936    }
937
938    #[test]
939    fn fnv1a64_byte_differs_for_different_input() {
940        let a = fnv1a64_byte(FNV_OFFSET_BASIS, 0x01);
941        let b = fnv1a64_byte(FNV_OFFSET_BASIS, 0x02);
942        assert_ne!(a, b);
943    }
944
945    #[test]
946    fn fnv1a64_bytes_empty() {
947        let hash = fnv1a64_bytes(FNV_OFFSET_BASIS, &[]);
948        assert_eq!(hash, FNV_OFFSET_BASIS);
949    }
950
951    #[test]
952    fn fnv1a64_bytes_consistent_with_single_byte() {
953        let from_bytes = fnv1a64_bytes(FNV_OFFSET_BASIS, &[0x42]);
954        let from_byte = fnv1a64_byte(FNV_OFFSET_BASIS, 0x42);
955        assert_eq!(from_bytes, from_byte);
956    }
957
958    #[test]
959    fn fnv1a64_u16_is_le_bytes() {
960        let from_u16 = fnv1a64_u16(FNV_OFFSET_BASIS, 0x1234);
961        let from_bytes = fnv1a64_bytes(FNV_OFFSET_BASIS, &0x1234u16.to_le_bytes());
962        assert_eq!(from_u16, from_bytes);
963    }
964
965    #[test]
966    fn fnv1a64_u32_is_le_bytes() {
967        let from_u32 = fnv1a64_u32(FNV_OFFSET_BASIS, 0xDEAD_BEEF);
968        let from_bytes = fnv1a64_bytes(FNV_OFFSET_BASIS, &0xDEAD_BEEFu32.to_le_bytes());
969        assert_eq!(from_u32, from_bytes);
970    }
971
972    #[test]
973    fn fnv1a64_pair_deterministic() {
974        let a = fnv1a64_pair(123, 456);
975        let b = fnv1a64_pair(123, 456);
976        assert_eq!(a, b);
977    }
978
979    #[test]
980    fn fnv1a64_pair_differs_for_different_input() {
981        let a = fnv1a64_pair(123, 456);
982        let b = fnv1a64_pair(456, 123);
983        assert_ne!(a, b);
984    }
985
986    #[test]
987    fn fnv1a64_bytes_long_input() {
988        // Test the 8-byte unrolled loop path
989        let data: Vec<u8> = (0..32).collect();
990        let hash = fnv1a64_bytes(FNV_OFFSET_BASIS, &data);
991        let hash2 = fnv1a64_bytes(FNV_OFFSET_BASIS, &data);
992        assert_eq!(hash, hash2);
993        // Different data should produce different hash
994        let mut data2 = data.clone();
995        data2[15] = 255;
996        assert_ne!(hash, fnv1a64_bytes(FNV_OFFSET_BASIS, &data2));
997    }
998
999    // --- Config builder tests ---
1000
1001    #[test]
1002    fn config_default_is_disabled() {
1003        let config = RenderTraceConfig::default();
1004        assert!(!config.enabled);
1005        assert_eq!(config.output_path, PathBuf::from("trace.jsonl"));
1006        assert!(config.run_id.is_none());
1007        assert!(config.seed.is_none());
1008        assert!(config.test_module.is_none());
1009        assert!(config.flush_on_write);
1010        assert!(!config.include_start_ts_ms);
1011    }
1012
1013    #[test]
1014    fn config_enabled_file() {
1015        let config = RenderTraceConfig::enabled_file("/tmp/test.jsonl");
1016        assert!(config.enabled);
1017        assert_eq!(config.output_path, PathBuf::from("/tmp/test.jsonl"));
1018    }
1019
1020    #[test]
1021    fn config_builder_chain() {
1022        let config = RenderTraceConfig::enabled_file("/tmp/test.jsonl")
1023            .with_run_id("test-run-1")
1024            .with_seed(42)
1025            .with_test_module("my_module")
1026            .with_flush_on_write(false)
1027            .with_start_ts_ms(true);
1028
1029        assert!(config.enabled);
1030        assert_eq!(config.run_id.as_deref(), Some("test-run-1"));
1031        assert_eq!(config.seed, Some(42));
1032        assert_eq!(config.test_module.as_deref(), Some("my_module"));
1033        assert!(!config.flush_on_write);
1034        assert!(config.include_start_ts_ms);
1035    }
1036
1037    // --- Recorder disabled config ---
1038
1039    #[test]
1040    fn recorder_disabled_returns_none() {
1041        let config = RenderTraceConfig::default(); // disabled
1042        let caps = TerminalCapabilities::default();
1043        let context = RenderTraceContext {
1044            capabilities: &caps,
1045            diff_config: RuntimeDiffConfig::default(),
1046            resize_config: CoalescerConfig::default(),
1047            conformal_config: None,
1048        };
1049        let result = RenderTraceRecorder::from_config(&config, context).expect("no io error");
1050        assert!(result.is_none());
1051    }
1052
1053    // --- Finish idempotence ---
1054
1055    #[test]
1056    fn recorder_finish_is_idempotent() {
1057        let path = temp_trace_path("idempotent");
1058        let config = RenderTraceConfig::enabled_file(&path);
1059        let caps = TerminalCapabilities::default();
1060        let context = RenderTraceContext {
1061            capabilities: &caps,
1062            diff_config: RuntimeDiffConfig::default(),
1063            resize_config: CoalescerConfig::default(),
1064            conformal_config: None,
1065        };
1066        let mut recorder = RenderTraceRecorder::from_config(&config, context)
1067            .expect("config")
1068            .expect("enabled");
1069
1070        recorder.finish(Some(10)).expect("first finish");
1071        recorder.finish(Some(20)).expect("second finish");
1072
1073        // Only one summary line should be written
1074        let text = std::fs::read_to_string(&path).expect("read");
1075        let summary_count = text.matches("\"event\":\"trace_summary\"").count();
1076        assert_eq!(summary_count, 1);
1077    }
1078
1079    // --- Checksum tests ---
1080
1081    #[test]
1082    fn checksum_1x1_buffer() {
1083        let buffer = Buffer::new(1, 1);
1084        let pool = GraphemePool::new();
1085        let hash = checksum_buffer(&buffer, &pool);
1086        // 1x1 empty buffer should produce a consistent non-basis hash
1087        let hash2 = checksum_buffer(&buffer, &pool);
1088        assert_eq!(hash, hash2);
1089        assert_ne!(hash, FNV_OFFSET_BASIS, "1x1 should differ from basis");
1090    }
1091
1092    #[test]
1093    fn checksum_differs_for_different_content() {
1094        let pool = GraphemePool::new();
1095        let mut buf_a = Buffer::new(2, 1);
1096        buf_a.set(0, 0, Cell::from_char('A'));
1097
1098        let mut buf_b = Buffer::new(2, 1);
1099        buf_b.set(0, 0, Cell::from_char('B'));
1100
1101        assert_ne!(
1102            checksum_buffer(&buf_a, &pool),
1103            checksum_buffer(&buf_b, &pool)
1104        );
1105    }
1106
1107    #[test]
1108    fn checksum_differs_for_different_dimensions() {
1109        let pool = GraphemePool::new();
1110        let buf_a = Buffer::new(2, 2);
1111        let buf_b = Buffer::new(3, 2);
1112        // Different grid dimensions → different checksums
1113        assert_ne!(
1114            checksum_buffer(&buf_a, &pool),
1115            checksum_buffer(&buf_b, &pool)
1116        );
1117    }
1118
1119    // --- Payload kind ---
1120
1121    #[test]
1122    fn payload_kind_as_str() {
1123        assert_eq!(RenderTracePayloadKind::DiffRunsV1.as_str(), "diff_runs_v1");
1124        assert_eq!(
1125            RenderTracePayloadKind::FullBufferV1.as_str(),
1126            "full_buffer_v1"
1127        );
1128    }
1129
1130    // --- Full buffer payload ---
1131
1132    #[test]
1133    fn build_full_buffer_payload_deterministic() {
1134        let mut buffer = Buffer::new(3, 2);
1135        buffer.set(0, 0, Cell::from_char('X'));
1136        buffer.set(1, 0, Cell::from_char('Y'));
1137        let pool = GraphemePool::new();
1138
1139        let p1 = build_full_buffer_payload(&buffer, &pool);
1140        let p2 = build_full_buffer_payload(&buffer, &pool);
1141        assert_eq!(p1.kind, RenderTracePayloadKind::FullBufferV1);
1142        assert_eq!(p1.bytes, p2.bytes);
1143    }
1144
1145    #[test]
1146    fn build_full_buffer_payload_starts_with_dimensions() {
1147        let buffer = Buffer::new(4, 3);
1148        let pool = GraphemePool::new();
1149        let payload = build_full_buffer_payload(&buffer, &pool);
1150
1151        // First 4 bytes: width (u16 LE) + height (u16 LE)
1152        assert!(payload.bytes.len() >= 4);
1153        let w = u16::from_le_bytes([payload.bytes[0], payload.bytes[1]]);
1154        let h = u16::from_le_bytes([payload.bytes[2], payload.bytes[3]]);
1155        assert_eq!(w, 4);
1156        assert_eq!(h, 3);
1157    }
1158
1159    // --- pack_attrs ---
1160
1161    #[test]
1162    fn pack_attrs_default() {
1163        let attrs = CellAttrs::default();
1164        let packed = pack_attrs(attrs);
1165        // Default attrs should have 0 flags and 0 link_id
1166        assert_eq!(packed, 0);
1167    }
1168
1169    // --- JSONL format tests ---
1170
1171    #[test]
1172    fn frame_to_jsonl_valid_json() {
1173        let frame = RenderTraceFrame {
1174            cols: 80,
1175            rows: 24,
1176            mode: "inline",
1177            ui_height: 20,
1178            ui_anchor: "bottom",
1179            diff_strategy: "dirty_rows",
1180            diff_cells: 100,
1181            diff_runs: 5,
1182            present_bytes: 512,
1183            render_us: Some(50),
1184            present_us: Some(30),
1185            payload_kind: "full_buffer_v1",
1186            payload_path: Some("trace_payloads/frame_000000_full_buffer_v1.bin"),
1187            trace_us: Some(10),
1188        };
1189
1190        let line = frame.to_jsonl(0, 0xDEADBEEF, 0xCAFEBABE);
1191        assert!(line.starts_with('{'));
1192        assert!(line.ends_with('}'));
1193        assert!(line.contains("\"event\":\"frame\""));
1194        assert!(line.contains("\"frame_idx\":0"));
1195        assert!(line.contains("\"cols\":80"));
1196        assert!(line.contains("\"rows\":24"));
1197        assert!(line.contains("\"mode\":\"inline\""));
1198        assert!(line.contains("\"checksum\":\"00000000deadbeef\""));
1199        assert!(line.contains("\"checksum_chain\":\"00000000cafebabe\""));
1200        assert!(line.contains("\"diff_strategy\":\"dirty_rows\""));
1201    }
1202
1203    #[test]
1204    fn frame_to_jsonl_null_optionals() {
1205        let frame = RenderTraceFrame {
1206            cols: 10,
1207            rows: 5,
1208            mode: "alt",
1209            ui_height: 5,
1210            ui_anchor: "top",
1211            diff_strategy: "full",
1212            diff_cells: 50,
1213            diff_runs: 1,
1214            present_bytes: 100,
1215            render_us: None,
1216            present_us: None,
1217            payload_kind: "none",
1218            payload_path: None,
1219            trace_us: None,
1220        };
1221
1222        let line = frame.to_jsonl(1, 0, 0);
1223        assert!(line.contains("\"render_us\":null"));
1224        assert!(line.contains("\"present_us\":null"));
1225        assert!(line.contains("\"payload_path\":null"));
1226        assert!(line.contains("\"trace_us\":null"));
1227    }
1228
1229    #[test]
1230    fn summary_to_jsonl_format() {
1231        let summary = RenderTraceSummary {
1232            total_frames: 100,
1233            final_checksum_chain: 0xABCDEF0123456789,
1234            elapsed_ms: Some(5000),
1235        };
1236        let line = summary.to_jsonl();
1237        assert!(line.contains("\"event\":\"trace_summary\""));
1238        assert!(line.contains("\"total_frames\":100"));
1239        assert!(line.contains("\"final_checksum_chain\":\"abcdef0123456789\""));
1240        assert!(line.contains("\"elapsed_ms\":5000"));
1241    }
1242
1243    #[test]
1244    fn summary_to_jsonl_null_elapsed() {
1245        let summary = RenderTraceSummary {
1246            total_frames: 0,
1247            final_checksum_chain: 0,
1248            elapsed_ms: None,
1249        };
1250        let line = summary.to_jsonl();
1251        assert!(line.contains("\"elapsed_ms\":null"));
1252    }
1253
1254    // --- Header JSONL ---
1255
1256    #[test]
1257    fn header_to_jsonl_format() {
1258        let header = RenderTraceHeader {
1259            run_id: "test-run".to_string(),
1260            seed: Some(42),
1261            env: RenderTraceEnv {
1262                os: "linux".to_string(),
1263                arch: "x86_64".to_string(),
1264                test_module: Some("my_test".to_string()),
1265            },
1266            capabilities: RenderTraceCapabilities {
1267                profile: "kitty".to_string(),
1268                true_color: true,
1269                colors_256: true,
1270                sync_output: true,
1271                osc8_hyperlinks: false,
1272                scroll_region: true,
1273                in_tmux: false,
1274                in_screen: false,
1275                in_zellij: false,
1276                kitty_keyboard: true,
1277                focus_events: true,
1278                bracketed_paste: true,
1279                mouse_sgr: true,
1280                osc52_clipboard: false,
1281            },
1282            policies: RenderTracePolicies {
1283                diff_bayesian: true,
1284                diff_dirty_rows: true,
1285                diff_dirty_spans: false,
1286                diff_guard_band: 2,
1287                diff_merge_gap: 4,
1288                bocpd_enabled: true,
1289                steady_delay_ms: 100,
1290                burst_delay_ms: 16,
1291                conformal_enabled: false,
1292                conformal_alpha: None,
1293                conformal_min_samples: None,
1294                conformal_window_size: None,
1295            },
1296            start_ts_ms: None,
1297        };
1298
1299        let line = header.to_jsonl();
1300        assert!(line.contains("\"event\":\"trace_header\""));
1301        assert!(line.contains("\"schema_version\":\"render-trace-v1\""));
1302        assert!(line.contains("\"run_id\":\"test-run\""));
1303        assert!(line.contains("\"seed\":42"));
1304        assert!(line.contains("\"start_ts_ms\":null"));
1305    }
1306
1307    // --- Env JSONL ---
1308
1309    #[test]
1310    fn env_to_json_format() {
1311        let env = RenderTraceEnv {
1312            os: "linux".to_string(),
1313            arch: "x86_64".to_string(),
1314            test_module: None,
1315        };
1316        let json = env.to_json();
1317        assert!(json.contains("\"os\":\"linux\""));
1318        assert!(json.contains("\"arch\":\"x86_64\""));
1319        assert!(json.contains("\"test_module\":null"));
1320    }
1321
1322    #[test]
1323    fn env_to_json_with_test_module() {
1324        let env = RenderTraceEnv {
1325            os: "macos".to_string(),
1326            arch: "aarch64".to_string(),
1327            test_module: Some("integration".to_string()),
1328        };
1329        let json = env.to_json();
1330        assert!(json.contains("\"test_module\":\"integration\""));
1331    }
1332
1333    // --- Capabilities JSONL ---
1334
1335    #[test]
1336    fn capabilities_to_json_format() {
1337        let caps = RenderTraceCapabilities {
1338            profile: "xterm".to_string(),
1339            true_color: false,
1340            colors_256: true,
1341            sync_output: false,
1342            osc8_hyperlinks: false,
1343            scroll_region: true,
1344            in_tmux: true,
1345            in_screen: false,
1346            in_zellij: false,
1347            kitty_keyboard: false,
1348            focus_events: false,
1349            bracketed_paste: true,
1350            mouse_sgr: false,
1351            osc52_clipboard: false,
1352        };
1353        let json = caps.to_json();
1354        assert!(json.contains("\"profile\":\"xterm\""));
1355        assert!(json.contains("\"true_color\":false"));
1356        assert!(json.contains("\"in_tmux\":true"));
1357    }
1358
1359    // --- Policies JSONL ---
1360
1361    #[test]
1362    fn policies_to_json_with_conformal() {
1363        let policies = RenderTracePolicies {
1364            diff_bayesian: true,
1365            diff_dirty_rows: true,
1366            diff_dirty_spans: true,
1367            diff_guard_band: 3,
1368            diff_merge_gap: 5,
1369            bocpd_enabled: true,
1370            steady_delay_ms: 100,
1371            burst_delay_ms: 16,
1372            conformal_enabled: true,
1373            conformal_alpha: Some(0.05),
1374            conformal_min_samples: Some(10),
1375            conformal_window_size: Some(100),
1376        };
1377        let json = policies.to_json();
1378        assert!(json.contains("\"diff\":{"));
1379        assert!(json.contains("\"bocpd\":{"));
1380        assert!(json.contains("\"conformal\":{"));
1381        assert!(json.contains("\"enabled\":true"));
1382        assert!(json.contains("\"guard_band\":3"));
1383    }
1384
1385    #[test]
1386    fn policies_to_json_without_conformal() {
1387        let policies = RenderTracePolicies {
1388            diff_bayesian: false,
1389            diff_dirty_rows: false,
1390            diff_dirty_spans: false,
1391            diff_guard_band: 0,
1392            diff_merge_gap: 0,
1393            bocpd_enabled: false,
1394            steady_delay_ms: 0,
1395            burst_delay_ms: 0,
1396            conformal_enabled: false,
1397            conformal_alpha: None,
1398            conformal_min_samples: None,
1399            conformal_window_size: None,
1400        };
1401        let json = policies.to_json();
1402        assert!(json.contains("\"alpha\":null"));
1403        assert!(json.contains("\"min_samples\":null"));
1404        assert!(json.contains("\"window_size\":null"));
1405    }
1406
1407    // --- Write payload ---
1408
1409    #[test]
1410    fn write_payload_creates_file() {
1411        let path = temp_trace_path("payload");
1412        let config = RenderTraceConfig::enabled_file(&path);
1413        let caps = TerminalCapabilities::default();
1414        let context = RenderTraceContext {
1415            capabilities: &caps,
1416            diff_config: RuntimeDiffConfig::default(),
1417            resize_config: CoalescerConfig::default(),
1418            conformal_config: None,
1419        };
1420        let mut recorder = RenderTraceRecorder::from_config(&config, context)
1421            .expect("config")
1422            .expect("enabled");
1423
1424        let payload = RenderTracePayload {
1425            kind: RenderTracePayloadKind::FullBufferV1,
1426            bytes: vec![1, 2, 3, 4],
1427        };
1428        let info = recorder.write_payload(&payload).expect("write");
1429        assert_eq!(info.kind, "full_buffer_v1");
1430        assert!(info.path.contains("frame_000000"));
1431        assert!(info.path.contains("full_buffer_v1.bin"));
1432    }
1433
1434    // --- Multiple frames advance index ---
1435
1436    #[test]
1437    fn record_multiple_frames_increments_index() {
1438        let path = temp_trace_path("multi");
1439        let config = RenderTraceConfig::enabled_file(&path);
1440        let caps = TerminalCapabilities::default();
1441        let context = RenderTraceContext {
1442            capabilities: &caps,
1443            diff_config: RuntimeDiffConfig::default(),
1444            resize_config: CoalescerConfig::default(),
1445            conformal_config: None,
1446        };
1447        let mut recorder = RenderTraceRecorder::from_config(&config, context)
1448            .expect("config")
1449            .expect("enabled");
1450
1451        let buffer = Buffer::new(2, 1);
1452        let pool = GraphemePool::new();
1453
1454        for _ in 0..3 {
1455            let frame = RenderTraceFrame {
1456                cols: 2,
1457                rows: 1,
1458                mode: "inline",
1459                ui_height: 1,
1460                ui_anchor: "bottom",
1461                diff_strategy: "full",
1462                diff_cells: 2,
1463                diff_runs: 1,
1464                present_bytes: 8,
1465                render_us: None,
1466                present_us: None,
1467                payload_kind: "none",
1468                payload_path: None,
1469                trace_us: None,
1470            };
1471            recorder.record_frame(frame, &buffer, &pool).expect("frame");
1472        }
1473        recorder.finish(None).expect("finish");
1474
1475        let text = std::fs::read_to_string(&path).expect("read");
1476        assert!(text.contains("\"frame_idx\":0"));
1477        assert!(text.contains("\"frame_idx\":1"));
1478        assert!(text.contains("\"frame_idx\":2"));
1479    }
1480
1481    // --- Config with seed and run_id in header ---
1482
1483    #[test]
1484    fn recorder_header_includes_seed_and_run_id() {
1485        let path = temp_trace_path("seed");
1486        let config = RenderTraceConfig::enabled_file(&path)
1487            .with_run_id("my-test-run")
1488            .with_seed(12345)
1489            .with_test_module("test_mod");
1490        let caps = TerminalCapabilities::default();
1491        let context = RenderTraceContext {
1492            capabilities: &caps,
1493            diff_config: RuntimeDiffConfig::default(),
1494            resize_config: CoalescerConfig::default(),
1495            conformal_config: None,
1496        };
1497        let mut recorder = RenderTraceRecorder::from_config(&config, context)
1498            .expect("config")
1499            .expect("enabled");
1500        recorder.finish(None).expect("finish");
1501
1502        let text = std::fs::read_to_string(&path).expect("read");
1503        assert!(text.contains("\"run_id\":\"my-test-run\""));
1504        assert!(text.contains("\"seed\":12345"));
1505        assert!(text.contains("\"test_module\":\"test_mod\""));
1506    }
1507}