#![forbid(unsafe_code)]
use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use ftui_render::buffer::Buffer;
const CHECKSUM_PREFIX: &str = "blake3:";
pub fn compute_buffer_checksum(buf: &Buffer) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(&buf.width().to_le_bytes());
hasher.update(&buf.height().to_le_bytes());
for y in 0..buf.height() {
for x in 0..buf.width() {
if let Some(cell) = buf.get(x, y) {
hasher.update(&cell.content.raw().to_le_bytes());
hasher.update(&cell.fg.0.to_le_bytes());
hasher.update(&cell.bg.0.to_le_bytes());
hasher.update(&[cell.attrs.flags().bits()]);
hasher.update(&cell.attrs.link_id().to_le_bytes());
}
}
}
let hash = hasher.finalize();
format!("{CHECKSUM_PREFIX}{hash}")
}
pub fn compute_text_checksum(text: &str) -> String {
let hash = blake3::hash(text.as_bytes());
format!("{CHECKSUM_PREFIX}{hash}")
}
#[derive(Debug, Clone)]
pub struct GoldenEnv {
pub term: String,
pub colorterm: String,
pub no_color: bool,
pub tmux: bool,
pub screen: bool,
pub zellij: bool,
pub wezterm_unix_socket: bool,
pub wezterm_pane: bool,
pub seed: u64,
pub rust_version: String,
pub git_commit: String,
pub git_branch: String,
}
impl GoldenEnv {
pub fn capture() -> Self {
Self {
term: std::env::var("TERM").unwrap_or_default(),
colorterm: std::env::var("COLORTERM").unwrap_or_default(),
no_color: std::env::var("NO_COLOR").is_ok(),
tmux: std::env::var("TMUX").is_ok(),
screen: std::env::var("STY").is_ok(),
zellij: std::env::var("ZELLIJ").is_ok(),
wezterm_unix_socket: std::env::var("WEZTERM_UNIX_SOCKET").is_ok(),
wezterm_pane: std::env::var("WEZTERM_PANE").is_ok(),
seed: std::env::var("GOLDEN_SEED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0),
rust_version: rustc_version(),
git_commit: git_commit(),
git_branch: git_branch(),
}
}
pub fn to_json(&self) -> String {
format!(
r#"{{"term":"{}","colorterm":"{}","no_color":{},"tmux":{},"screen":{},"zellij":{},"wezterm_unix_socket":{},"wezterm_pane":{},"seed":{},"rust_version":"{}","git_commit":"{}","git_branch":"{}"}}"#,
escape_json(&self.term),
escape_json(&self.colorterm),
self.no_color,
self.tmux,
self.screen,
self.zellij,
self.wezterm_unix_socket,
self.wezterm_pane,
self.seed,
escape_json(&self.rust_version),
escape_json(&self.git_commit),
escape_json(&self.git_branch),
)
}
}
fn rustc_version() -> String {
std::process::Command::new("rustc")
.arg("--version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".into())
}
fn git_commit() -> String {
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".into())
}
fn git_branch() -> String {
std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".into())
}
fn escape_json(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
pub struct GoldenLogger {
writer: Option<BufWriter<File>>,
run_id: String,
start_time: Instant,
checksums: Vec<String>,
}
impl GoldenLogger {
pub fn new(path: &Path) -> std::io::Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(path)?;
Ok(Self {
writer: Some(BufWriter::new(file)),
run_id: generate_run_id(),
start_time: Instant::now(),
checksums: Vec::new(),
})
}
pub fn noop() -> Self {
Self {
writer: None,
run_id: generate_run_id(),
start_time: Instant::now(),
checksums: Vec::new(),
}
}
pub fn log_start(&mut self, case: &str, env: &GoldenEnv) {
let timestamp = iso_timestamp();
self.write_line(&format!(
r#"{{"event":"start","run_id":"{}","case":"{}","env":{},"seed":{},"timestamp":"{}"}}"#,
self.run_id,
escape_json(case),
env.to_json(),
env.seed,
timestamp,
));
}
pub fn log_frame(
&mut self,
frame_id: u32,
width: u16,
height: u16,
checksum: &str,
timing_ms: u64,
) {
self.checksums.push(checksum.to_string());
self.write_line(&format!(
r#"{{"event":"frame","run_id":"{}","frame_id":{},"width":{},"height":{},"checksum":"{}","timing_ms":{}}}"#,
self.run_id, frame_id, width, height, escape_json(checksum), timing_ms,
));
}
pub fn log_resize(&mut self, from_w: u16, from_h: u16, to_w: u16, to_h: u16, timing_ms: u64) {
self.write_line(&format!(
r#"{{"event":"resize","run_id":"{}","from":"{}x{}","to":"{}x{}","timing_ms":{}}}"#,
self.run_id, from_w, from_h, to_w, to_h, timing_ms,
));
}
pub fn log_complete(&mut self, outcome: GoldenOutcome) {
let total_ms = self.start_time.elapsed().as_millis() as u64;
let checksums_json: String = self
.checksums
.iter()
.map(|c| format!(r#""{}""#, escape_json(c)))
.collect::<Vec<_>>()
.join(",");
self.write_line(&format!(
r#"{{"event":"complete","run_id":"{}","outcome":"{}","checksums":[{}],"total_ms":{}}}"#,
self.run_id,
outcome.as_str(),
checksums_json,
total_ms,
));
}
pub fn log_error(&mut self, message: &str) {
self.write_line(&format!(
r#"{{"event":"error","run_id":"{}","message":"{}","timestamp":"{}"}}"#,
self.run_id,
escape_json(message),
iso_timestamp(),
));
}
pub fn checksums(&self) -> &[String] {
&self.checksums
}
fn write_line(&mut self, line: &str) {
if let Some(ref mut writer) = self.writer {
let _ = writeln!(writer, "{line}");
let _ = writer.flush();
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoldenOutcome {
Pass,
Fail,
Skip,
}
impl GoldenOutcome {
fn as_str(self) -> &'static str {
match self {
Self::Pass => "pass",
Self::Fail => "fail",
Self::Skip => "skip",
}
}
}
fn generate_run_id() -> String {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("{timestamp:x}")
}
fn iso_timestamp() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{now}")
}
#[derive(Debug, Clone)]
pub struct ResizeScenario {
pub name: String,
pub initial_width: u16,
pub initial_height: u16,
pub resize_steps: Vec<(u16, u16, u64)>,
pub expected_checksums: Vec<String>,
}
impl ResizeScenario {
pub fn fixed(name: &str, width: u16, height: u16) -> Self {
Self {
name: name.to_string(),
initial_width: width,
initial_height: height,
resize_steps: Vec::new(),
expected_checksums: Vec::new(),
}
}
pub fn resize(name: &str, from_w: u16, from_h: u16, to_w: u16, to_h: u16) -> Self {
Self {
name: name.to_string(),
initial_width: from_w,
initial_height: from_h,
resize_steps: vec![(to_w, to_h, 0)],
expected_checksums: Vec::new(),
}
}
#[must_use]
pub fn with_expected(mut self, checksums: Vec<String>) -> Self {
self.expected_checksums = checksums;
self
}
}
pub fn standard_resize_scenarios() -> Vec<ResizeScenario> {
vec![
ResizeScenario::fixed("fixed_80x24", 80, 24),
ResizeScenario::fixed("fixed_120x40", 120, 40),
ResizeScenario::fixed("fixed_60x15", 60, 15),
ResizeScenario::fixed("fixed_40x10", 40, 10),
ResizeScenario::fixed("fixed_200x60", 200, 60),
ResizeScenario::resize("resize_80x24_to_120x40", 80, 24, 120, 40),
ResizeScenario::resize("resize_120x40_to_80x24", 120, 40, 80, 24),
ResizeScenario::resize("resize_80x24_to_40x10", 80, 24, 40, 10),
ResizeScenario::resize("resize_40x10_to_200x60", 40, 10, 200, 60),
]
}
pub fn golden_checksum_path(base_dir: &Path, scenario_name: &str) -> PathBuf {
base_dir
.join("tests")
.join("golden")
.join(format!("{scenario_name}.checksums"))
}
pub fn load_golden_checksums(path: &Path) -> std::io::Result<Vec<String>> {
match fs::read_to_string(path) {
Ok(content) => Ok(content
.lines()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| l.trim().to_string())
.collect()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
pub fn save_golden_checksums(path: &Path, checksums: &[String]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = format!(
"# Golden checksums - do not edit manually\n# Generated at: {}\n{}\n",
iso_timestamp(),
checksums.join("\n")
);
fs::write(path, content)
}
pub fn is_bless_mode() -> bool {
std::env::var("BLESS").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
pub fn is_golden_enforced() -> bool {
let explicit = std::env::var("FTUI_GOLDEN_ENFORCE")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
let ci = std::env::var("CI").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
explicit || ci
}
#[derive(Debug)]
pub struct GoldenResult {
pub scenario: String,
pub outcome: GoldenOutcome,
pub checksums: Vec<String>,
pub expected_checksums: Vec<String>,
pub mismatch_index: Option<usize>,
pub duration_ms: u64,
}
impl GoldenResult {
pub fn is_pass(&self) -> bool {
self.outcome == GoldenOutcome::Pass
}
pub fn format(&self) -> String {
match self.outcome {
GoldenOutcome::Pass => format!("PASS: {} ({}ms)", self.scenario, self.duration_ms),
GoldenOutcome::Fail => {
if self.expected_checksums.is_empty() {
format!("FAIL: {} - missing golden checksums", self.scenario)
} else if let Some(idx) = self.mismatch_index {
format!(
"FAIL: {} - checksum mismatch at frame {}\n expected: {}\n actual: {}",
self.scenario,
idx,
self.expected_checksums
.get(idx)
.unwrap_or(&"<none>".to_string()),
self.checksums.get(idx).unwrap_or(&"<none>".to_string()),
)
} else {
format!("FAIL: {} - checksum count mismatch", self.scenario)
}
}
GoldenOutcome::Skip => format!("SKIP: {}", self.scenario),
}
}
}
pub fn verify_checksums(actual: &[String], expected: &[String]) -> (GoldenOutcome, Option<usize>) {
let span = tracing::info_span!(
"golden.compare",
actual_count = actual.len(),
expected_count = expected.len(),
outcome = tracing::field::Empty,
mismatch_frame = tracing::field::Empty,
);
let _guard = span.enter();
if expected.is_empty() {
if is_golden_enforced() {
tracing::error!(
actual_count = actual.len(),
"golden checksums enforced but no expected checksums found"
);
span.record("outcome", "fail");
return (GoldenOutcome::Fail, None);
}
span.record("outcome", "pass");
return (GoldenOutcome::Pass, None);
}
if actual.len() != expected.len() {
tracing::error!(
actual_count = actual.len(),
expected_count = expected.len(),
"golden checksum count mismatch"
);
span.record("outcome", "fail");
return (GoldenOutcome::Fail, None);
}
for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
if a != e {
tracing::error!(
frame = i,
expected_hash = %e,
actual_hash = %a,
"golden checksum mismatch"
);
span.record("outcome", "fail");
span.record("mismatch_frame", i);
return (GoldenOutcome::Fail, Some(i));
}
}
span.record("outcome", "pass");
(GoldenOutcome::Pass, None)
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::cell::Cell;
#[test]
fn test_compute_buffer_checksum_empty() {
let buf = Buffer::new(10, 5);
let checksum = compute_buffer_checksum(&buf);
assert!(checksum.starts_with(CHECKSUM_PREFIX));
assert_eq!(checksum.len(), CHECKSUM_PREFIX.len() + 64);
}
#[test]
fn test_compute_buffer_checksum_deterministic() {
let mut buf = Buffer::new(10, 5);
buf.set(0, 0, Cell::from_char('A'));
buf.set(1, 0, Cell::from_char('B'));
let checksum1 = compute_buffer_checksum(&buf);
let checksum2 = compute_buffer_checksum(&buf);
assert_eq!(checksum1, checksum2);
}
#[test]
fn test_compute_buffer_checksum_differs_on_content() {
let mut buf1 = Buffer::new(10, 5);
buf1.set(0, 0, Cell::from_char('A'));
let mut buf2 = Buffer::new(10, 5);
buf2.set(0, 0, Cell::from_char('B'));
let checksum1 = compute_buffer_checksum(&buf1);
let checksum2 = compute_buffer_checksum(&buf2);
assert_ne!(checksum1, checksum2);
}
#[test]
fn test_compute_buffer_checksum_differs_on_size() {
let buf1 = Buffer::new(10, 5);
let buf2 = Buffer::new(11, 5);
let checksum1 = compute_buffer_checksum(&buf1);
let checksum2 = compute_buffer_checksum(&buf2);
assert_ne!(checksum1, checksum2);
}
#[test]
fn test_compute_text_checksum() {
let text = "Hello, World!";
let checksum = compute_text_checksum(text);
assert!(checksum.starts_with(CHECKSUM_PREFIX));
assert_eq!(checksum, compute_text_checksum(text));
}
#[test]
fn test_golden_env_capture() {
let env = GoldenEnv::capture();
let json = env.to_json();
assert!(json.contains("term"));
assert!(json.contains("seed"));
}
#[test]
fn test_escape_json() {
assert_eq!(escape_json("hello"), "hello");
assert_eq!(escape_json("he\"llo"), "he\\\"llo");
assert_eq!(escape_json("he\\llo"), "he\\\\llo");
assert_eq!(escape_json("line1\nline2"), "line1\\nline2");
}
#[test]
fn test_verify_checksums_pass() {
let actual = vec!["blake3:abc".to_string(), "blake3:def".to_string()];
let expected = vec!["blake3:abc".to_string(), "blake3:def".to_string()];
let (outcome, idx) = verify_checksums(&actual, &expected);
assert_eq!(outcome, GoldenOutcome::Pass);
assert!(idx.is_none());
}
#[test]
fn test_verify_checksums_mismatch() {
let actual = vec!["blake3:abc".to_string(), "blake3:xyz".to_string()];
let expected = vec!["blake3:abc".to_string(), "blake3:def".to_string()];
let (outcome, idx) = verify_checksums(&actual, &expected);
assert_eq!(outcome, GoldenOutcome::Fail);
assert_eq!(idx, Some(1));
}
#[test]
fn test_verify_checksums_length_mismatch() {
let actual = vec!["blake3:abc".to_string()];
let expected = vec!["blake3:abc".to_string(), "blake3:def".to_string()];
let (outcome, idx) = verify_checksums(&actual, &expected);
assert_eq!(outcome, GoldenOutcome::Fail);
assert!(idx.is_none());
}
#[test]
fn test_verify_checksums_empty_expected() {
let actual = vec!["blake3:abc".to_string()];
let expected: Vec<String> = vec![];
let (outcome, _) = verify_checksums(&actual, &expected);
assert_eq!(outcome, GoldenOutcome::Pass);
}
#[test]
fn test_resize_scenario_fixed() {
let scenario = ResizeScenario::fixed("test", 80, 24);
assert_eq!(scenario.name, "test");
assert_eq!(scenario.initial_width, 80);
assert_eq!(scenario.initial_height, 24);
assert!(scenario.resize_steps.is_empty());
}
#[test]
fn test_resize_scenario_resize() {
let scenario = ResizeScenario::resize("test", 80, 24, 120, 40);
assert_eq!(scenario.initial_width, 80);
assert_eq!(scenario.initial_height, 24);
assert_eq!(scenario.resize_steps.len(), 1);
assert_eq!(scenario.resize_steps[0], (120, 40, 0));
}
#[test]
fn test_standard_scenarios() {
let scenarios = standard_resize_scenarios();
assert!(!scenarios.is_empty());
assert!(scenarios.iter().any(|s| s.resize_steps.is_empty()));
assert!(scenarios.iter().any(|s| !s.resize_steps.is_empty()));
}
#[test]
fn outcome_as_str() {
assert_eq!(GoldenOutcome::Pass.as_str(), "pass");
assert_eq!(GoldenOutcome::Fail.as_str(), "fail");
assert_eq!(GoldenOutcome::Skip.as_str(), "skip");
}
#[test]
fn result_format_pass() {
let r = GoldenResult {
scenario: "test".into(),
outcome: GoldenOutcome::Pass,
checksums: vec![],
expected_checksums: vec![],
mismatch_index: None,
duration_ms: 42,
};
assert!(r.is_pass());
let s = r.format();
assert!(s.contains("PASS"), "{s}");
assert!(s.contains("42ms"), "{s}");
}
#[test]
fn result_format_fail_missing_golden() {
let r = GoldenResult {
scenario: "test".into(),
outcome: GoldenOutcome::Fail,
checksums: vec!["blake3:abc".into()],
expected_checksums: vec![],
mismatch_index: None,
duration_ms: 0,
};
assert!(!r.is_pass());
let s = r.format();
assert!(s.contains("missing golden checksums"), "{s}");
}
#[test]
fn result_format_fail_mismatch() {
let r = GoldenResult {
scenario: "test".into(),
outcome: GoldenOutcome::Fail,
checksums: vec!["blake3:abc".into(), "blake3:wrong".into()],
expected_checksums: vec!["blake3:abc".into(), "blake3:def".into()],
mismatch_index: Some(1),
duration_ms: 0,
};
let s = r.format();
assert!(s.contains("checksum mismatch at frame 1"), "{s}");
assert!(s.contains("blake3:def"), "expected: {s}");
assert!(s.contains("blake3:wrong"), "actual: {s}");
}
#[test]
fn result_format_fail_count_mismatch() {
let r = GoldenResult {
scenario: "test".into(),
outcome: GoldenOutcome::Fail,
checksums: vec!["blake3:abc".into()],
expected_checksums: vec!["blake3:abc".into(), "blake3:def".into()],
mismatch_index: None,
duration_ms: 0,
};
let s = r.format();
assert!(s.contains("checksum count mismatch"), "{s}");
}
#[test]
fn result_format_skip() {
let r = GoldenResult {
scenario: "test".into(),
outcome: GoldenOutcome::Skip,
checksums: vec![],
expected_checksums: vec![],
mismatch_index: None,
duration_ms: 0,
};
assert!(r.format().starts_with("SKIP:"));
}
#[test]
fn noop_logger_does_not_crash() {
let mut logger = GoldenLogger::noop();
let env = GoldenEnv::capture();
logger.log_start("test_case", &env);
logger.log_frame(0, 80, 24, "blake3:abc", 10);
logger.log_resize(80, 24, 120, 40, 5);
logger.log_error("some error");
logger.log_complete(GoldenOutcome::Pass);
assert_eq!(logger.checksums(), &["blake3:abc".to_string()]);
}
#[test]
fn file_logger_writes_events() {
let dir = std::env::temp_dir().join(format!(
"ftui_golden_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let log_path = dir.join("test.jsonl");
{
let mut logger = GoldenLogger::new(&log_path).expect("create logger");
let env = GoldenEnv::capture();
logger.log_start("test", &env);
logger.log_frame(0, 80, 24, "blake3:aaa", 1);
logger.log_resize(80, 24, 120, 40, 2);
logger.log_frame(1, 120, 40, "blake3:bbb", 3);
logger.log_complete(GoldenOutcome::Pass);
}
let content = std::fs::read_to_string(&log_path).expect("read log");
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 5, "should have 5 JSONL events");
assert!(lines[0].contains("\"event\":\"start\""));
assert!(lines[1].contains("\"event\":\"frame\""));
assert!(lines[2].contains("\"event\":\"resize\""));
assert!(lines[3].contains("\"event\":\"frame\""));
assert!(lines[4].contains("\"event\":\"complete\""));
assert!(lines[4].contains("\"outcome\":\"pass\""));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_and_load_golden_checksums() {
let dir = std::env::temp_dir().join(format!(
"ftui_golden_io_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let path = dir.join("tests").join("golden").join("test.checksums");
let checksums = vec!["blake3:abc".to_string(), "blake3:def".to_string()];
save_golden_checksums(&path, &checksums).expect("save");
let loaded = load_golden_checksums(&path).expect("load");
assert_eq!(loaded, checksums);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_golden_checksums_nonexistent_returns_empty() {
let path = std::path::Path::new("/tmp/nonexistent_golden_12345.checksums");
let loaded = load_golden_checksums(path).expect("should return empty");
assert!(loaded.is_empty());
}
#[test]
fn load_golden_checksums_skips_comments_and_blanks() {
let dir = std::env::temp_dir().join(format!(
"ftui_golden_comments_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.checksums");
std::fs::write(
&path,
"# comment\nblake3:abc\n\nblake3:def\n# another comment\n",
)
.unwrap();
let loaded = load_golden_checksums(&path).expect("load");
assert_eq!(loaded, vec!["blake3:abc", "blake3:def"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn golden_checksum_path_format() {
let base = std::path::Path::new("/project");
let path = golden_checksum_path(base, "resize_80x24");
assert_eq!(
path,
std::path::PathBuf::from("/project/tests/golden/resize_80x24.checksums")
);
}
#[test]
fn resize_scenario_with_expected() {
let scenario =
ResizeScenario::fixed("test", 80, 24).with_expected(vec!["blake3:abc".into()]);
assert_eq!(scenario.expected_checksums, vec!["blake3:abc"]);
}
#[test]
fn golden_env_to_json_is_valid() {
let env = GoldenEnv::capture();
let json = env.to_json();
let parsed: serde_json::Value =
serde_json::from_str(&json).expect("GoldenEnv::to_json should produce valid JSON");
assert!(parsed.get("term").is_some());
assert!(parsed.get("seed").is_some());
assert!(parsed.get("rust_version").is_some());
assert!(parsed.get("git_commit").is_some());
}
#[test]
fn blake3_hash_prefix_is_correct() {
let buf = Buffer::new(1, 1);
let checksum = compute_buffer_checksum(&buf);
assert!(
checksum.starts_with("blake3:"),
"checksum must start with 'blake3:' prefix"
);
let hex_part = &checksum["blake3:".len()..];
assert_eq!(hex_part.len(), 64, "BLAKE3 hex digest must be 64 chars");
assert!(
hex_part.chars().all(|c| c.is_ascii_hexdigit()),
"digest must be valid hex"
);
}
#[test]
fn blake3_hash_sensitive_to_fg_color() {
use ftui_render::cell::PackedRgba;
let mut buf1 = Buffer::new(5, 1);
let mut cell1 = Cell::from_char('X');
cell1.fg = PackedRgba::rgb(255, 0, 0); buf1.set(0, 0, cell1);
let mut buf2 = Buffer::new(5, 1);
let mut cell2 = Cell::from_char('X');
cell2.fg = PackedRgba::rgb(0, 0, 255); buf2.set(0, 0, cell2);
assert_ne!(
compute_buffer_checksum(&buf1),
compute_buffer_checksum(&buf2),
"different foreground colors must produce different hashes"
);
}
#[test]
fn blake3_hash_sensitive_to_bg_color() {
use ftui_render::cell::PackedRgba;
let mut buf1 = Buffer::new(5, 1);
let mut cell1 = Cell::from_char('X');
cell1.bg = PackedRgba::rgb(0, 255, 0); buf1.set(0, 0, cell1);
let mut buf2 = Buffer::new(5, 1);
let mut cell2 = Cell::from_char('X');
cell2.bg = PackedRgba::rgb(255, 255, 0); buf2.set(0, 0, cell2);
assert_ne!(
compute_buffer_checksum(&buf1),
compute_buffer_checksum(&buf2),
"different background colors must produce different hashes"
);
}
#[test]
fn blake3_hash_sensitive_to_cell_position() {
let mut buf1 = Buffer::new(5, 1);
buf1.set(0, 0, Cell::from_char('A'));
let mut buf2 = Buffer::new(5, 1);
buf2.set(1, 0, Cell::from_char('A'));
assert_ne!(
compute_buffer_checksum(&buf1),
compute_buffer_checksum(&buf2),
"same char at different positions must produce different hashes"
);
}
#[test]
fn blake3_text_checksum_differs_for_different_text() {
let c1 = compute_text_checksum("hello");
let c2 = compute_text_checksum("world");
assert_ne!(c1, c2);
}
#[test]
fn blake3_text_checksum_empty_string() {
let c = compute_text_checksum("");
assert!(c.starts_with("blake3:"));
assert_eq!(c.len(), "blake3:".len() + 64);
}
#[test]
fn blake3_hash_dimensions_included() {
let buf1 = Buffer::new(10, 5); let buf2 = Buffer::new(5, 10); assert_ne!(
compute_buffer_checksum(&buf1),
compute_buffer_checksum(&buf2),
"different dimensions must produce different hashes even with same cell count"
);
}
#[test]
fn save_creates_parent_directories() {
let dir = std::env::temp_dir().join(format!(
"ftui_golden_mkdir_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let deeply_nested = dir.join("a").join("b").join("c").join("test.checksums");
let checksums = vec!["blake3:abc123".to_string()];
save_golden_checksums(&deeply_nested, &checksums).expect("save should create dirs");
assert!(deeply_nested.exists());
let loaded = load_golden_checksums(&deeply_nested).expect("load");
assert_eq!(loaded, checksums);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_golden_includes_header_comment() {
let dir = std::env::temp_dir().join(format!(
"ftui_golden_header_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let path = dir.join("test.checksums");
let checksums = vec!["blake3:aaa".to_string()];
save_golden_checksums(&path, &checksums).expect("save");
let raw = std::fs::read_to_string(&path).expect("read");
assert!(
raw.starts_with("# Golden checksums"),
"file should start with header comment"
);
assert!(raw.contains("# Generated at:"), "should have timestamp");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_and_load_empty_checksums() {
let dir = std::env::temp_dir().join(format!(
"ftui_golden_empty_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let path = dir.join("test.checksums");
save_golden_checksums(&path, &[]).expect("save empty");
let loaded = load_golden_checksums(&path).expect("load");
assert!(loaded.is_empty(), "empty save should load as empty");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_and_load_many_checksums() {
let dir = std::env::temp_dir().join(format!(
"ftui_golden_many_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let path = dir.join("test.checksums");
let checksums: Vec<String> = (0..100).map(|i| format!("blake3:{i:064x}")).collect();
save_golden_checksums(&path, &checksums).expect("save");
let loaded = load_golden_checksums(&path).expect("load");
assert_eq!(loaded, checksums);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn verify_checksums_both_empty_is_pass() {
let (outcome, idx) = verify_checksums(&[], &[]);
assert_eq!(outcome, GoldenOutcome::Pass);
assert!(idx.is_none());
}
#[test]
fn verify_checksums_first_frame_mismatch() {
let actual = vec!["blake3:aaa".to_string()];
let expected = vec!["blake3:bbb".to_string()];
let (outcome, idx) = verify_checksums(&actual, &expected);
assert_eq!(outcome, GoldenOutcome::Fail);
assert_eq!(idx, Some(0), "mismatch should be at frame 0");
}
#[test]
fn verify_checksums_last_frame_mismatch() {
let actual = vec![
"blake3:aaa".to_string(),
"blake3:bbb".to_string(),
"blake3:xxx".to_string(),
];
let expected = vec![
"blake3:aaa".to_string(),
"blake3:bbb".to_string(),
"blake3:ccc".to_string(),
];
let (outcome, idx) = verify_checksums(&actual, &expected);
assert_eq!(outcome, GoldenOutcome::Fail);
assert_eq!(idx, Some(2), "mismatch should be at last frame");
}
#[test]
fn verify_checksums_actual_shorter() {
let actual: Vec<String> = vec![];
let expected = vec!["blake3:abc".to_string()];
let (outcome, _) = verify_checksums(&actual, &expected);
assert_eq!(outcome, GoldenOutcome::Fail);
}
#[test]
fn verify_checksums_actual_longer() {
let actual = vec!["blake3:abc".to_string(), "blake3:def".to_string()];
let expected = vec!["blake3:abc".to_string()];
let (outcome, _) = verify_checksums(&actual, &expected);
assert_eq!(outcome, GoldenOutcome::Fail);
}
#[test]
fn bless_mode_round_trip() {
let dir = std::env::temp_dir().join(format!(
"ftui_golden_bless_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let path = golden_checksum_path(&dir, "bless_test");
let mut buf = Buffer::new(80, 24);
buf.set(0, 0, Cell::from_char('H'));
buf.set(1, 0, Cell::from_char('i'));
let checksum = compute_buffer_checksum(&buf);
save_golden_checksums(&path, std::slice::from_ref(&checksum)).expect("save");
let loaded = load_golden_checksums(&path).expect("load");
let (outcome, idx) = verify_checksums(&[checksum], &loaded);
assert_eq!(outcome, GoldenOutcome::Pass);
assert!(idx.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bless_mode_overwrites_old_golden() {
let dir = std::env::temp_dir().join(format!(
"ftui_golden_overwrite_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let path = golden_checksum_path(&dir, "overwrite_test");
save_golden_checksums(&path, &["blake3:old_hash".to_string()]).expect("save old");
let loaded_old = load_golden_checksums(&path).expect("load old");
assert_eq!(loaded_old, vec!["blake3:old_hash"]);
save_golden_checksums(&path, &["blake3:new_hash".to_string()]).expect("save new");
let loaded_new = load_golden_checksums(&path).expect("load new");
assert_eq!(loaded_new, vec!["blake3:new_hash"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn verify_checksums_emits_golden_compare_span() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct SpanChecker {
saw_golden_compare: Arc<AtomicBool>,
}
impl tracing::Subscriber for SpanChecker {
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
if span.metadata().name() == "golden.compare" {
self.saw_golden_compare.store(true, Ordering::Relaxed);
}
tracing::span::Id::from_u64(1)
}
fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
fn event(&self, _: &tracing::Event<'_>) {}
fn enter(&self, _: &tracing::span::Id) {}
fn exit(&self, _: &tracing::span::Id) {}
}
let saw_it = Arc::new(AtomicBool::new(false));
let subscriber = SpanChecker {
saw_golden_compare: Arc::clone(&saw_it),
};
let _guard = tracing::subscriber::set_default(subscriber);
let actual = vec!["blake3:abc".to_string()];
let expected = vec!["blake3:abc".to_string()];
let _ = verify_checksums(&actual, &expected);
assert!(
saw_it.load(Ordering::Relaxed),
"verify_checksums() must emit a 'golden.compare' tracing span"
);
}
#[test]
fn verify_checksums_emits_error_on_mismatch_with_both_hashes() {
use std::sync::Arc;
use std::sync::Mutex;
struct ErrorCollector {
errors: Arc<Mutex<Vec<String>>>,
}
impl tracing::Subscriber for ErrorCollector {
fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
if *event.metadata().level() == tracing::Level::ERROR {
let mut collector = ErrorFieldCollector { fields: Vec::new() };
event.record(&mut collector);
self.errors
.lock()
.unwrap()
.push(collector.fields.join(", "));
}
}
fn enter(&self, _: &tracing::span::Id) {}
fn exit(&self, _: &tracing::span::Id) {}
}
struct ErrorFieldCollector {
fields: Vec<String>,
}
impl tracing::field::Visit for ErrorFieldCollector {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.fields.push(format!("{}={:?}", field.name(), value));
}
}
let errors = Arc::new(Mutex::new(Vec::new()));
let subscriber = ErrorCollector {
errors: Arc::clone(&errors),
};
let _guard = tracing::subscriber::set_default(subscriber);
let actual = vec!["blake3:actual_hash".to_string()];
let expected = vec!["blake3:expected_hash".to_string()];
let (outcome, idx) = verify_checksums(&actual, &expected);
assert_eq!(outcome, GoldenOutcome::Fail);
assert_eq!(idx, Some(0));
let collected = errors.lock().unwrap();
assert!(
!collected.is_empty(),
"should emit at least one ERROR event on mismatch"
);
let error_msg = collected.join(" ");
assert!(
error_msg.contains("expected_hash") || error_msg.contains("actual_hash"),
"ERROR should include hash values, got: {error_msg}"
);
}
#[test]
fn verify_checksums_emits_error_on_count_mismatch() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct CountMismatchChecker {
saw_error: Arc<AtomicBool>,
}
impl tracing::Subscriber for CountMismatchChecker {
fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
if *event.metadata().level() == tracing::Level::ERROR {
self.saw_error.store(true, Ordering::Relaxed);
}
}
fn enter(&self, _: &tracing::span::Id) {}
fn exit(&self, _: &tracing::span::Id) {}
}
let saw = Arc::new(AtomicBool::new(false));
let subscriber = CountMismatchChecker {
saw_error: Arc::clone(&saw),
};
let _guard = tracing::subscriber::set_default(subscriber);
let actual = vec!["blake3:abc".to_string()];
let expected = vec!["blake3:abc".to_string(), "blake3:def".to_string()];
let _ = verify_checksums(&actual, &expected);
assert!(
saw.load(Ordering::Relaxed),
"count mismatch should emit ERROR event"
);
}
#[test]
fn verify_checksums_span_records_outcome_pass() {
use std::sync::Arc;
use std::sync::Mutex;
struct OutcomeRecorder {
outcome: Arc<Mutex<Option<String>>>,
}
struct OutcomeVisitor(Arc<Mutex<Option<String>>>);
impl tracing::field::Visit for OutcomeVisitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "outcome" {
*self.0.lock().unwrap() = Some(value.to_string());
}
}
fn record_debug(&mut self, _: &tracing::field::Field, _: &dyn std::fmt::Debug) {}
}
impl tracing::Subscriber for OutcomeRecorder {
fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _: &tracing::span::Id, values: &tracing::span::Record<'_>) {
let mut v = OutcomeVisitor(Arc::clone(&self.outcome));
values.record(&mut v);
}
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
fn event(&self, _: &tracing::Event<'_>) {}
fn enter(&self, _: &tracing::span::Id) {}
fn exit(&self, _: &tracing::span::Id) {}
}
let outcome = Arc::new(Mutex::new(None));
let subscriber = OutcomeRecorder {
outcome: Arc::clone(&outcome),
};
let _guard = tracing::subscriber::set_default(subscriber);
let actual = vec!["blake3:abc".to_string()];
let expected = vec!["blake3:abc".to_string()];
let _ = verify_checksums(&actual, &expected);
let recorded = outcome.lock().unwrap();
assert_eq!(
recorded.as_deref(),
Some("pass"),
"outcome field should be 'pass'"
);
}
#[test]
fn result_format_mismatch_includes_both_hashes() {
let expected_hash =
"blake3:aaaa1111bbbb2222cccc3333dddd4444eeee5555ffff6666aabb7788ccdd9900";
let actual_hash = "blake3:1111aaaa2222bbbb3333cccc4444dddd5555eeee6666ffff7788aabb9900ccdd";
let r = GoldenResult {
scenario: "test".into(),
outcome: GoldenOutcome::Fail,
checksums: vec![actual_hash.to_string()],
expected_checksums: vec![expected_hash.to_string()],
mismatch_index: Some(0),
duration_ms: 5,
};
let s = r.format();
assert!(
s.contains(expected_hash),
"should contain expected hash in error output"
);
assert!(
s.contains(actual_hash),
"should contain actual hash in error output"
);
}
#[test]
fn logger_collects_checksums_in_order() {
let mut logger = GoldenLogger::noop();
logger.log_frame(0, 80, 24, "blake3:first", 1);
logger.log_frame(1, 80, 24, "blake3:second", 2);
logger.log_frame(2, 80, 24, "blake3:third", 3);
assert_eq!(
logger.checksums(),
&["blake3:first", "blake3:second", "blake3:third"]
);
}
#[test]
fn logger_error_event_format() {
let dir = std::env::temp_dir().join(format!(
"ftui_golden_error_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let log_path = dir.join("test.jsonl");
{
let mut logger = GoldenLogger::new(&log_path).expect("create logger");
logger.log_error("something went wrong");
}
let content = std::fs::read_to_string(&log_path).expect("read log");
assert!(content.contains("\"event\":\"error\""));
assert!(content.contains("something went wrong"));
let _ = std::fs::remove_dir_all(&dir);
}
}