1#![forbid(unsafe_code)]
2
3use 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
26pub const RENDER_TRACE_SCHEMA_VERSION: &str = "render-trace-v2";
28
29#[derive(Debug, Clone)]
31pub struct RenderTraceConfig {
32 pub enabled: bool,
34 pub output_path: PathBuf,
36 pub run_id: Option<String>,
38 pub seed: Option<u64>,
40 pub test_module: Option<String>,
42 pub flush_on_write: bool,
44 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 #[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 #[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 #[must_use]
82 pub fn with_seed(mut self, seed: u64) -> Self {
83 self.seed = Some(seed);
84 self
85 }
86
87 #[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 #[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 #[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#[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
118pub 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#[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#[derive(Debug, Clone)]
153pub struct RenderTracePayload {
154 pub kind: RenderTracePayloadKind,
155 pub bytes: Vec<u8>,
156}
157
158#[derive(Debug, Clone)]
160pub struct RenderTracePayloadInfo {
161 pub kind: &'static str,
162 pub path: String,
163}
164
165impl RenderTraceRecorder {
166 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 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 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 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#[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#[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#[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#[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#[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#[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 #[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 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 #[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 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 let mut data2 = data.clone();
997 data2[15] = 255;
998 assert_ne!(hash, fnv1a64_bytes(FNV_OFFSET_BASIS, &data2));
999 }
1000
1001 #[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 #[test]
1042 fn recorder_disabled_returns_none() {
1043 let config = RenderTraceConfig::default(); 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 #[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 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 #[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 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 assert_ne!(
1116 checksum_buffer(&buf_a, &pool),
1117 checksum_buffer(&buf_b, &pool)
1118 );
1119 }
1120
1121 #[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 #[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 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 #[test]
1164 fn pack_attrs_default() {
1165 let attrs = CellAttrs::default();
1166 let packed = pack_attrs(attrs);
1167 assert_eq!(packed, 0);
1169 }
1170
1171 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}