use std::path::{Path, PathBuf};
use std::{env, fs};
use image::GenericImageView;
const UPDATE_ENV_VAR: &str = "TUI_TEST_UPDATE";
const DEFAULT_PIXEL_THRESHOLD: f64 = 30.0;
const DEFAULT_DIFF_PERCENT_THRESHOLD: f64 = 10.0;
#[derive(Debug, Clone)]
#[must_use]
pub struct SnapshotConfig {
pub baseline_dir: PathBuf,
pub artifact_dir: PathBuf,
pub pixel_threshold: f64,
pub diff_percent_threshold: f64,
}
impl SnapshotConfig {
pub fn new(baseline_dir: impl Into<PathBuf>, artifact_dir: impl Into<PathBuf>) -> Self {
Self {
baseline_dir: baseline_dir.into(),
artifact_dir: artifact_dir.into(),
pixel_threshold: DEFAULT_PIXEL_THRESHOLD,
diff_percent_threshold: DEFAULT_DIFF_PERCENT_THRESHOLD,
}
}
pub fn with_thresholds(mut self, pixel_threshold: f64, diff_percent_threshold: f64) -> Self {
self.pixel_threshold = pixel_threshold;
self.diff_percent_threshold = diff_percent_threshold;
self
}
}
pub fn is_update_mode() -> bool {
env::var(UPDATE_ENV_VAR).is_ok()
}
pub fn assert_snapshot_matches(
config: &SnapshotConfig,
name: &str,
actual_screenshot: &Path,
) -> Result<(), SnapshotError> {
fs::create_dir_all(&config.baseline_dir)
.map_err(|err| SnapshotError::IoError(err.to_string()))?;
fs::create_dir_all(&config.artifact_dir)
.map_err(|err| SnapshotError::IoError(err.to_string()))?;
let baseline_path = config.baseline_dir.join(format!("{name}.png"));
if is_update_mode() {
fs::copy(actual_screenshot, &baseline_path)
.map_err(|err| SnapshotError::IoError(err.to_string()))?;
return Ok(());
}
if !baseline_path.exists() {
return Err(SnapshotError::MissingBaseline {
name: name.to_string(),
baseline_path,
});
}
let diff_percent =
compare_screenshots(actual_screenshot, &baseline_path, config.pixel_threshold)?;
if diff_percent > config.diff_percent_threshold {
let actual_artifact = config.artifact_dir.join(format!("{name}_actual.png"));
fs::copy(actual_screenshot, &actual_artifact)
.map_err(|err| SnapshotError::IoError(err.to_string()))?;
return Err(SnapshotError::Mismatch {
name: name.to_string(),
diff_percent,
threshold: config.diff_percent_threshold,
baseline_path,
actual_path: actual_artifact,
});
}
Ok(())
}
pub fn assert_frame_snapshot_matches(
config: &SnapshotConfig,
name: &str,
actual_text: &str,
) -> Result<(), SnapshotError> {
fs::create_dir_all(&config.baseline_dir)
.map_err(|err| SnapshotError::IoError(err.to_string()))?;
fs::create_dir_all(&config.artifact_dir)
.map_err(|err| SnapshotError::IoError(err.to_string()))?;
let baseline_path = config.baseline_dir.join(format!("{name}_frame.txt"));
if is_update_mode() {
fs::write(&baseline_path, actual_text)
.map_err(|err| SnapshotError::IoError(err.to_string()))?;
return Ok(());
}
if !baseline_path.exists() {
return Err(SnapshotError::MissingBaseline {
name: name.to_string(),
baseline_path,
});
}
let expected_text = fs::read_to_string(&baseline_path)
.map_err(|err| SnapshotError::IoError(err.to_string()))?;
if actual_text != expected_text {
let actual_artifact = config.artifact_dir.join(format!("{name}_frame_actual.txt"));
fs::write(&actual_artifact, actual_text)
.map_err(|err| SnapshotError::IoError(err.to_string()))?;
return Err(SnapshotError::FrameMismatch {
name: name.to_string(),
expected: expected_text,
actual: actual_text.to_string(),
});
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum SnapshotError {
#[error(
"Snapshot '{name}' mismatch: {diff_percent:.1}% pixels differ \
(threshold: {threshold}%).\nBaseline: {}\nActual: {}",
baseline_path.display(),
actual_path.display()
)]
Mismatch {
name: String,
diff_percent: f64,
threshold: f64,
baseline_path: PathBuf,
actual_path: PathBuf,
},
#[error(
"Missing baseline for '{name}'. Run with {UPDATE_ENV_VAR}=1 to create it.\n\
Expected: {}",
baseline_path.display()
)]
MissingBaseline {
name: String,
baseline_path: PathBuf,
},
#[error("Frame snapshot '{name}' mismatch")]
FrameMismatch {
name: String,
expected: String,
actual: String,
},
#[error("I/O error: {0}")]
IoError(String),
#[error("Image error: {0}")]
ImageError(String),
}
fn compare_screenshots(
actual_path: &Path,
reference_path: &Path,
pixel_threshold: f64,
) -> Result<f64, SnapshotError> {
let actual_img =
image::open(actual_path).map_err(|err| SnapshotError::ImageError(err.to_string()))?;
let reference_img =
image::open(reference_path).map_err(|err| SnapshotError::ImageError(err.to_string()))?;
let (actual_width, actual_height) = actual_img.dimensions();
let (ref_width, ref_height) = reference_img.dimensions();
if actual_width != ref_width || actual_height != ref_height {
return Err(SnapshotError::Mismatch {
name: "size".to_string(),
diff_percent: 100.0,
threshold: 0.0,
baseline_path: reference_path.to_path_buf(),
actual_path: actual_path.to_path_buf(),
});
}
let total_pixels = f64::from(actual_width) * f64::from(actual_height);
let mut different_pixels: f64 = 0.0;
for pixel_y in 0..actual_height {
for pixel_x in 0..actual_width {
let actual_pixel = actual_img.get_pixel(pixel_x, pixel_y);
let reference_pixel = reference_img.get_pixel(pixel_x, pixel_y);
let distance = pixel_distance(actual_pixel.0, reference_pixel.0);
if distance > pixel_threshold {
different_pixels += 1.0;
}
}
}
Ok((different_pixels / total_pixels) * 100.0)
}
fn pixel_distance(pixel_a: [u8; 4], pixel_b: [u8; 4]) -> f64 {
let red_diff = f64::from(pixel_a[0]) - f64::from(pixel_b[0]);
let green_diff = f64::from(pixel_a[1]) - f64::from(pixel_b[1]);
let blue_diff = f64::from(pixel_a[2]) - f64::from(pixel_b[2]);
(red_diff * red_diff + green_diff * green_diff + blue_diff * blue_diff).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pixel_distance_identical_is_zero() {
let pixel = [100, 150, 200, 255];
let distance = pixel_distance(pixel, pixel);
assert!(distance.abs() < f64::EPSILON);
}
#[test]
fn pixel_distance_opposite_colors() {
let pixel_a = [0, 0, 0, 255];
let pixel_b = [255, 255, 255, 255];
let distance = pixel_distance(pixel_a, pixel_b);
assert!(distance > 441.0);
assert!(distance < 442.0);
}
#[test]
fn pixel_distance_ignores_alpha() {
let pixel_a = [100, 100, 100, 0];
let pixel_b = [100, 100, 100, 255];
let distance = pixel_distance(pixel_a, pixel_b);
assert!(distance.abs() < f64::EPSILON);
}
#[test]
fn frame_snapshot_returns_missing_baseline_error_outside_update_mode() {
let temp = tempfile::TempDir::new().expect("failed to create temp dir");
let config =
SnapshotConfig::new(temp.path().join("baselines"), temp.path().join("artifacts"));
let result = assert_frame_snapshot_matches(&config, "test", "Hello World");
assert!(
matches!(result, Err(SnapshotError::MissingBaseline { .. })),
"expected MissingBaseline error, got {result:?}"
);
}
#[test]
fn frame_snapshot_matches_identical_content() {
let temp = tempfile::TempDir::new().expect("failed to create temp dir");
let config =
SnapshotConfig::new(temp.path().join("baselines"), temp.path().join("artifacts"));
let baseline_path = config.baseline_dir.join("test_frame.txt");
fs::create_dir_all(&config.baseline_dir).expect("failed to create baseline dir");
fs::write(&baseline_path, "Hello World").expect("failed to write baseline");
let result = assert_frame_snapshot_matches(&config, "test", "Hello World");
assert!(result.is_ok());
}
#[test]
fn frame_snapshot_detects_mismatch() {
let temp = tempfile::TempDir::new().expect("failed to create temp dir");
let config =
SnapshotConfig::new(temp.path().join("baselines"), temp.path().join("artifacts"));
let baseline_path = config.baseline_dir.join("test_frame.txt");
fs::create_dir_all(&config.baseline_dir).expect("failed to create baseline dir");
fs::write(&baseline_path, "Hello World").expect("failed to write baseline");
let result = assert_frame_snapshot_matches(&config, "test", "Goodbye World");
assert!(result.is_err());
}
#[test]
fn snapshot_config_with_custom_thresholds() {
let config = SnapshotConfig::new("/baselines", "/artifacts").with_thresholds(50.0, 20.0);
assert!((config.pixel_threshold - 50.0).abs() < f64::EPSILON);
assert!((config.diff_percent_threshold - 20.0).abs() < f64::EPSILON);
}
}