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