use std::path::{Path, PathBuf};
use image::RgbImage;
use crate::Result;
pub(crate) const UPDATE_ENV: &str = "TUI_LIPAN_UPDATE_BASELINES";
const DIFF_HIGHLIGHT: [u8; 3] = [255, 0, 128];
const DIFF_DIM_NUMERATOR: u32 = 3;
const DIFF_DIM_DENOMINATOR: u32 = 10;
#[derive(Clone, Debug, PartialEq)]
pub enum BaselineOutcome {
Created,
Match {
ratio: f64,
},
Changed {
ratio: f64,
changed_pixels: u64,
total_pixels: u64,
diff_path: PathBuf,
},
SizeChanged {
baseline: (u32, u32),
current: (u32, u32),
},
Updated,
}
impl BaselineOutcome {
pub fn is_regression(&self) -> bool {
matches!(self, Self::Changed { .. } | Self::SizeChanged { .. })
}
pub fn summary(&self, name: &str) -> String {
match self {
Self::Created => format!("{name}: baseline created"),
Self::Match { ratio } if *ratio == 0.0 => format!("{name}: identical"),
Self::Match { ratio } => {
format!(
"{name}: within tolerance ({:.4}% pixels differ)",
ratio * 100.0
)
}
Self::Changed {
ratio,
changed_pixels,
total_pixels,
diff_path,
} => format!(
"{name}: CHANGED - {changed_pixels}/{total_pixels} pixels ({:.4}%) differ; diff at {}",
ratio * 100.0,
diff_path.display()
),
Self::SizeChanged { baseline, current } => format!(
"{name}: CHANGED - size {}x{} -> {}x{}",
baseline.0, baseline.1, current.0, current.1
),
Self::Updated => format!("{name}: baseline updated"),
}
}
}
#[derive(Clone, Debug)]
pub struct BaselineComparison {
pub name: String,
pub baseline_path: PathBuf,
pub outcome: BaselineOutcome,
}
pub struct SnapshotBaseline {
snapshot: super::UiSnapshot,
dir: PathBuf,
name: Option<String>,
tolerance: f64,
}
impl SnapshotBaseline {
pub(crate) fn new(snapshot: super::UiSnapshot, dir: impl Into<PathBuf>) -> Self {
Self {
snapshot,
dir: dir.into(),
name: None,
tolerance: 0.0,
}
}
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn tolerance(mut self, ratio: f64) -> Self {
self.tolerance = ratio.clamp(0.0, 1.0);
self
}
fn capture_name(&self) -> String {
self.name.clone().unwrap_or_else(|| {
format!(
"snapshot-{}x{}",
self.snapshot.viewport.w, self.snapshot.viewport.h
)
})
}
pub fn check(self) -> Result<BaselineComparison> {
let name = self.capture_name();
let baseline_path = self.dir.join(format!("{name}.png"));
let deterministic = crate::capture::PngOptions {
text_renderer: crate::capture::PngTextRenderer::Bitmap,
..crate::capture::PngOptions::default()
};
let current = self.snapshot.to_png(&deterministic)?;
compare_or_create(&name, &baseline_path, ¤t, self.tolerance)
}
pub fn assert_baseline(self) -> Result<()> {
let comparison = self.check()?;
if !comparison.outcome.is_regression() {
return Ok(());
}
Err(std::io::Error::other(format!(
"1 visual baseline regression(s):\n {}\n\nRe-run with `{}=1` to accept them.",
comparison.outcome.summary(&comparison.name),
UPDATE_ENV,
))
.into())
}
}
pub(crate) fn update_mode() -> bool {
std::env::var(UPDATE_ENV).as_deref() == Ok("1")
}
pub(crate) fn compare_or_create(
name: &str,
baseline_path: &Path,
current_png: &[u8],
tolerance: f64,
) -> Result<BaselineComparison> {
let outcome = compare_outcome(baseline_path, current_png, tolerance)?;
Ok(BaselineComparison {
name: name.to_owned(),
baseline_path: baseline_path.to_path_buf(),
outcome,
})
}
fn compare_outcome(
baseline_path: &Path,
current_png: &[u8],
tolerance: f64,
) -> Result<BaselineOutcome> {
if let Some(parent) = baseline_path.parent() {
std::fs::create_dir_all(parent)?;
}
if !baseline_path.exists() {
std::fs::write(baseline_path, current_png)?;
return Ok(BaselineOutcome::Created);
}
if update_mode() {
std::fs::write(baseline_path, current_png)?;
return Ok(BaselineOutcome::Updated);
}
let baseline_bytes = std::fs::read(baseline_path)?;
if baseline_bytes == current_png {
return Ok(BaselineOutcome::Match { ratio: 0.0 });
}
let baseline = decode(&baseline_bytes, "baseline")?;
let current = decode(current_png, "current capture")?;
if baseline.dimensions() != current.dimensions() {
return Ok(BaselineOutcome::SizeChanged {
baseline: baseline.dimensions(),
current: current.dimensions(),
});
}
let (changed_pixels, total_pixels) = count_differences(&baseline, ¤t);
let ratio = if total_pixels == 0 {
0.0
} else {
changed_pixels as f64 / total_pixels as f64
};
if ratio <= tolerance {
return Ok(BaselineOutcome::Match { ratio });
}
let diff_path = diff_path_for(baseline_path);
let diff = render_diff(&baseline, ¤t);
diff.save(&diff_path)
.map_err(|err| std::io::Error::other(err.to_string()))?;
Ok(BaselineOutcome::Changed {
ratio,
changed_pixels,
total_pixels,
diff_path,
})
}
fn decode(bytes: &[u8], what: &str) -> Result<RgbImage> {
image::load_from_memory(bytes)
.map(|image| image.to_rgb8())
.map_err(|err| std::io::Error::other(format!("failed to decode {what} PNG: {err}")).into())
}
fn count_differences(baseline: &RgbImage, current: &RgbImage) -> (u64, u64) {
let total = u64::from(baseline.width()) * u64::from(baseline.height());
let changed = baseline
.pixels()
.zip(current.pixels())
.filter(|(a, b)| a != b)
.count() as u64;
(changed, total)
}
fn render_diff(baseline: &RgbImage, current: &RgbImage) -> RgbImage {
let mut out = current.clone();
for (x, y, pixel) in out.enumerate_pixels_mut() {
let same = baseline.get_pixel(x, y) == current.get_pixel(x, y);
if same {
for channel in pixel.0.iter_mut() {
*channel =
((u32::from(*channel) * DIFF_DIM_NUMERATOR) / DIFF_DIM_DENOMINATOR) as u8;
}
} else {
pixel.0 = DIFF_HIGHLIGHT;
}
}
out
}
fn diff_path_for(baseline_path: &Path) -> PathBuf {
let stem = baseline_path
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_else(|| "baseline".to_owned());
baseline_path.with_file_name(format!("{stem}.diff.png"))
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(width: u32, height: u32, color: [u8; 3]) -> RgbImage {
RgbImage::from_pixel(width, height, image::Rgb(color))
}
fn encode(image: &RgbImage) -> Vec<u8> {
let mut out = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(image.clone())
.write_to(&mut out, image::ImageFormat::Png)
.expect("encode");
out.into_inner()
}
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"tui-lipan-baseline-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
fn missing_baseline_is_created_and_is_not_a_regression() {
let dir = temp_dir("create");
let path = dir.join("shot.png");
let png = encode(&solid(4, 4, [10, 20, 30]));
let result = compare_or_create("shot", &path, &png, 0.0).expect("compare");
assert_eq!(result.outcome, BaselineOutcome::Created);
assert!(!result.outcome.is_regression());
assert!(path.exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn identical_capture_matches_exactly() {
let dir = temp_dir("match");
let path = dir.join("shot.png");
let png = encode(&solid(4, 4, [10, 20, 30]));
std::fs::write(&path, &png).expect("seed baseline");
let result = compare_or_create("shot", &path, &png, 0.0).expect("compare");
assert_eq!(result.outcome, BaselineOutcome::Match { ratio: 0.0 });
assert!(!result.outcome.is_regression());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn changed_capture_reports_ratio_and_writes_a_diff_image() {
let dir = temp_dir("changed");
let path = dir.join("shot.png");
std::fs::write(&path, encode(&solid(4, 4, [0, 0, 0]))).expect("seed baseline");
let mut changed = solid(4, 4, [0, 0, 0]);
changed.put_pixel(1, 1, image::Rgb([255, 255, 255]));
let result = compare_or_create("shot", &path, &encode(&changed), 0.0).expect("compare");
match result.outcome {
BaselineOutcome::Changed {
changed_pixels,
total_pixels,
ref diff_path,
ratio,
} => {
assert_eq!(changed_pixels, 1);
assert_eq!(total_pixels, 16);
assert!((ratio - 0.0625).abs() < f64::EPSILON);
assert!(diff_path.exists(), "diff image should be written");
let diff = image::open(diff_path).expect("diff decodes").to_rgb8();
assert_eq!(diff.get_pixel(1, 1).0, DIFF_HIGHLIGHT);
}
other => panic!("expected Changed, got {other:?}"),
}
assert!(result.outcome.is_regression());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tolerance_absorbs_a_small_difference() {
let dir = temp_dir("tolerance");
let path = dir.join("shot.png");
std::fs::write(&path, encode(&solid(4, 4, [0, 0, 0]))).expect("seed baseline");
let mut changed = solid(4, 4, [0, 0, 0]);
changed.put_pixel(1, 1, image::Rgb([255, 255, 255]));
let result = compare_or_create("shot", &path, &encode(&changed), 0.1).expect("compare");
assert!(matches!(result.outcome, BaselineOutcome::Match { .. }));
assert!(!result.outcome.is_regression());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resized_capture_reports_size_change_without_pixel_math() {
let dir = temp_dir("resize");
let path = dir.join("shot.png");
std::fs::write(&path, encode(&solid(4, 4, [0, 0, 0]))).expect("seed baseline");
let result = compare_or_create("shot", &path, &encode(&solid(8, 4, [0, 0, 0])), 1.0)
.expect("compare");
assert_eq!(
result.outcome,
BaselineOutcome::SizeChanged {
baseline: (4, 4),
current: (8, 4),
}
);
assert!(result.outcome.is_regression());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn diff_path_sits_beside_the_baseline() {
assert_eq!(
diff_path_for(Path::new("/tmp/base/login-80x24.png")),
PathBuf::from("/tmp/base/login-80x24.diff.png")
);
}
}