use std::path::{Path, PathBuf};
use image::RgbaImage;
fn artifact_dir() -> PathBuf {
if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") {
return PathBuf::from(dir).join("snapshot-failures");
}
if let Ok(exe) = std::env::current_exe() {
for a in exe.ancestors() {
if a.file_name().is_some_and(|n| n == "target") {
return a.join("snapshot-failures");
}
}
}
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or_else(|| Path::new("."))
.join("target")
.join("snapshot-failures")
}
fn artifact_path(name: &str, kind: &str) -> PathBuf {
let p = artifact_dir().join(format!("{name}.{kind}.png"));
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).ok();
}
p
}
pub fn blessing() -> bool {
match std::env::var("UPDATE_SNAPSHOTS") {
Err(_) => false,
Ok(v) => match v.trim().to_ascii_lowercase().as_str() {
"" | "0" | "false" | "no" | "off" => false,
"1" | "true" | "yes" | "on" => true,
other => panic!(
"UPDATE_SNAPSHOTS={other:?} is not understood — use 1/true/yes/on to bless, or unset it to test"
),
},
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
Match,
Blessed,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Diff {
pub pixels: u64,
pub total: u64,
pub first: Option<(u32, u32)>,
pub bbox: Option<(u32, u32, u32, u32)>,
pub max_channel_delta: u8,
}
impl Diff {
pub fn percent(&self) -> f64 {
if self.total == 0 { 0.0 } else { self.pixels as f64 * 100.0 / self.total as f64 }
}
}
pub fn compare(golden: &RgbaImage, actual: &RgbaImage) -> Option<Diff> {
debug_assert_eq!(golden.dimensions(), actual.dimensions());
let (w, h) = golden.dimensions();
let (g, a) = (golden.as_raw(), actual.as_raw());
let mut d = Diff {
pixels: 0,
total: u64::from(w) * u64::from(h),
first: None,
bbox: None,
max_channel_delta: 0,
};
for i in 0..(g.len() / 4) {
let (gp, ap) = (&g[i * 4..i * 4 + 4], &a[i * 4..i * 4 + 4]);
if gp == ap {
continue;
}
let (x, y) = ((i as u32) % w, (i as u32) / w);
d.pixels += 1;
if d.first.is_none() {
d.first = Some((x, y));
}
d.bbox = Some(match d.bbox {
None => (x, y, x, y),
Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
});
for c in 0..4 {
d.max_channel_delta = d.max_channel_delta.max(gp[c].abs_diff(ap[c]));
}
}
(d.pixels > 0).then_some(d)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MinChange {
pub percent: f64,
pub extent: f64,
}
impl MinChange {
pub const PANE: Self = Self { percent: 0.5, extent: 0.10 };
pub const WIDGET: Self = Self { percent: 0.05, extent: 0.02 };
}
pub fn moved(before: &RgbaImage, after: &RgbaImage, min: MinChange) -> Result<Diff, String> {
if before.dimensions() != after.dimensions() {
return Err(format!(
"frames differ in SIZE ({:?} vs {:?}) — a resize is not proof that the \
interaction reached the pixels",
before.dimensions(),
after.dimensions()
));
}
let (w, h) = before.dimensions();
let Some(d) = compare(before, after) else {
return Err(
"the two frames are byte-identical — the interaction never reached the pixels"
.to_owned(),
);
};
if d.percent() < min.percent {
return Err(format!(
"only {:.4} % of the frame changed ({} of {} px), below the {:.4} % floor. \
That is the size of a text label or a port number, not a pane redrawing",
d.percent(),
d.pixels,
d.total,
min.percent
));
}
let (x0, y0, x1, y1) = d.bbox.expect("compare sets bbox when pixels differ");
let (bw, bh) = (x1 - x0 + 1, y1 - y0 + 1);
let (fw, fh) = (f64::from(bw) / f64::from(w), f64::from(bh) / f64::from(h));
if fw < min.extent || fh < min.extent {
return Err(format!(
"the change is a THIN STRIP: bbox {bw}×{bh} px at ({x0},{y0}) is {:.3}×{:.3} \
of the frame, below the {:.3} floor in {}. A window title, a status line or \
a legend looks exactly like this",
fw,
fh,
min.extent,
if fw < min.extent { "width" } else { "height" }
));
}
Ok(d)
}
pub fn stable(first: &RgbaImage, second: &RgbaImage) -> Result<(), String> {
if first.dimensions() != second.dimensions() {
return Err(format!(
"two renders of the SAME state came out at DIFFERENT SIZES ({:?} vs {:?}) — \
the harness is not reproducible, so no before/after pixel proof on it means \
anything",
first.dimensions(),
second.dimensions()
));
}
match compare(first, second) {
None => Ok(()),
Some(d) => {
let (x0, y0, x1, y1) = d.bbox.expect("compare sets bbox when pixels differ");
Err(format!(
"NOT DETERMINISTIC: two renders of the SAME state differ by {} of {} px \
({:.4} % of the frame), max channel delta {}, region x {x0}..={x1} \
y {y0}..={y1}. The noise floor is not zero, so a before/after `moved` \
pass on this host may be measuring jitter rather than the interaction. \
Pin the clock / the animation / the incidental text before trusting any \
pixel proof here",
d.pixels,
d.total,
d.percent(),
d.max_channel_delta
))
}
}
}
pub fn diff_image(golden: &RgbaImage, actual: &RgbaImage) -> RgbaImage {
let (w, h) = golden.dimensions();
let mut out = RgbaImage::new(w, h);
let (g, a) = (golden.as_raw(), actual.as_raw());
for (i, px) in out.pixels_mut().enumerate() {
let (gp, ap) = (&g[i * 4..i * 4 + 4], &a[i * 4..i * 4 + 4]);
*px = if gp == ap {
let lum = (u16::from(gp[0]) * 30 + u16::from(gp[1]) * 59 + u16::from(gp[2]) * 11) / 100;
let l = (lum / 3) as u8; image::Rgba([l, l, l, 255])
} else {
image::Rgba([255, 0, 190, 255])
};
}
out
}
#[track_caller]
pub fn assert_golden(actual: &RgbaImage, golden_path: &Path, name: &str) -> Verdict {
let (aw, ah) = actual.dimensions();
if blessing() {
if let Some(parent) = golden_path.parent() {
std::fs::create_dir_all(parent).ok();
}
actual.save(golden_path).unwrap_or_else(|e| panic!("[{name}] writing golden {}: {e}", golden_path.display()));
eprintln!("BLESSED {name} → {} ({aw}×{ah})", golden_path.display());
return Verdict::Blessed;
}
let dump = |why: String| -> ! {
let actual_path = artifact_path(name, "actual");
let saved = actual.save(&actual_path).is_ok();
let where_ = if saved {
format!("\n actual render → {}", actual_path.display())
} else {
String::new()
};
panic!(
"\nSNAPSHOT GUARD FAILED [{name}]\n {why}{where_}\n \
bless with: UPDATE_SNAPSHOTS=1 cargo test … (only when the change is intended)\n"
);
};
if !golden_path.exists() {
dump(format!(
"MISSING golden {} — a snapshot test with no committed golden is not a passing test.",
golden_path.display()
));
}
let golden = match image::open(golden_path) {
Ok(img) => img.to_rgba8(),
Err(e) => dump(format!(
"could not decode golden {}: {e}\n (a git-lfs pointer instead of a PNG? try `git lfs pull`)",
golden_path.display()
)),
};
let (gw, gh) = golden.dimensions();
if (gw, gh) != (aw, ah) {
dump(format!("size changed: golden is {gw}×{gh}, render is {aw}×{ah}"));
}
if let Some(d) = compare(&golden, actual) {
let diff_path = artifact_path(name, "diff");
let diff_note = if diff_image(&golden, actual).save(&diff_path).is_ok() {
format!("\n diff image → {} (magenta = changed)", diff_path.display())
} else {
String::new()
};
let (x0, y0, x1, y1) = d.bbox.unwrap_or((0, 0, 0, 0));
dump(format!(
"{} of {} pixels differ ({:.2}%), max channel delta {}\n \
first differing pixel at ({}, {}); changed region x {x0}..={x1}, y {y0}..={y1}\n \
golden → {}{diff_note}",
d.pixels,
d.total,
d.percent(),
d.max_channel_delta,
d.first.map_or(0, |p| p.0),
d.first.map_or(0, |p| p.1),
golden_path.display(),
));
}
eprintln!("golden OK {name} ({aw}×{ah}, exact) ← {}", golden_path.display());
Verdict::Match
}
#[cfg(test)]
mod tests {
use super::*;
fn img(w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> RgbaImage {
RgbaImage::from_fn(w, h, |x, y| image::Rgba(f(x, y)))
}
#[test]
fn moved_rejects_the_incidental_changes_that_fooled_assert_ne() {
let base = img(1100, 760, |_, _| [30, 30, 40, 255]);
let title = img(1100, 760, |_, y| {
if y < 18 { [200, 200, 210, 255] } else { [30, 30, 40, 255] }
});
let e = moved(&base, &title, MinChange::PANE).expect_err("a title strip is not a redraw");
assert!(e.contains("THIN STRIP"), "must name the strip shape, got: {e}");
let port = img(1100, 760, |x, y| {
if (40..96).contains(&x) && (700..712).contains(&y) {
[255, 255, 255, 255]
} else {
[30, 30, 40, 255]
}
});
let e = moved(&base, &port, MinChange::PANE).expect_err("a port number is not a redraw");
assert!(e.contains("below the"), "must name the floor it missed, got: {e}");
let legend = img(1100, 760, |x, y| {
if (900..1000).contains(&x) && (20..44).contains(&y) {
[180, 180, 90, 255]
} else {
[30, 30, 40, 255]
}
});
assert!(moved(&base, &legend, MinChange::PANE).is_err(), "a legend is not a redraw");
let pane = img(1100, 760, |x, y| {
if (100..700).contains(&x) && (100..600).contains(&y) {
[90, 140, 200, 255]
} else {
[30, 30, 40, 255]
}
});
let d = moved(&base, &pane, MinChange::PANE).expect("a redrawn pane must pass");
assert!(d.percent() > 30.0, "the accepted case really is large: {:.2} %", d.percent());
}
#[test]
fn moved_rejects_identical_frames_by_name() {
let a = img(64, 64, |_, _| [1, 2, 3, 255]);
let e = moved(&a, &a.clone(), MinChange::WIDGET).expect_err("identical must fail");
assert!(e.contains("byte-identical"), "got: {e}");
}
#[test]
fn moved_rejects_a_size_change_instead_of_comparing_it() {
let a = img(32, 32, |_, _| [0, 0, 0, 255]);
let b = img(64, 32, |_, _| [0, 0, 0, 255]);
let e = moved(&a, &b, MinChange::WIDGET).expect_err("a resize must not be a pass");
assert!(e.contains("SIZE"), "got: {e}");
}
#[test]
fn stable_accepts_a_reproducible_pair_and_refuses_one_jittering_pixel() {
let a = img(1100, 760, |x, y| [(x % 251) as u8, (y % 253) as u8, 40, 255]);
stable(&a, &a.clone()).expect("two identical renders are a zero noise floor");
let mut jitter = a.clone();
let p = *jitter.get_pixel(500, 400);
jitter.put_pixel(500, 400, image::Rgba([p.0[0] ^ 1, p.0[1], p.0[2], p.0[3]]));
let e = stable(&a, &jitter).expect_err("a jittering pixel must fail the control");
assert!(e.contains("NOT DETERMINISTIC"), "must name the finding, got: {e}");
assert!(e.contains("1 of 836000 px"), "must report the measured jitter, got: {e}");
}
#[test]
fn stable_refuses_a_size_change_as_a_broken_harness() {
let a = img(32, 32, |_, _| [0, 0, 0, 255]);
let b = img(32, 64, |_, _| [0, 0, 0, 255]);
let e = stable(&a, &b).expect_err("a resize between same-state renders must fail");
assert!(e.contains("DIFFERENT SIZES"), "got: {e}");
}
#[test]
fn identical_images_compare_equal() {
let a = img(8, 4, |x, y| [x as u8, y as u8, 7, 255]);
let b = a.clone();
assert_eq!(compare(&a, &b), None);
}
#[test]
fn one_changed_pixel_is_located_and_counted() {
let a = img(8, 4, |_, _| [10, 10, 10, 255]);
let mut b = a.clone();
b.put_pixel(5, 2, image::Rgba([10, 10, 13, 255]));
let d = compare(&a, &b).expect("a one-pixel change is a difference");
assert_eq!(d.pixels, 1);
assert_eq!(d.total, 32);
assert_eq!(d.first, Some((5, 2)));
assert_eq!(d.bbox, Some((5, 2, 5, 2)));
assert_eq!(d.max_channel_delta, 3);
assert!((d.percent() - 3.125).abs() < 1e-9);
}
#[test]
fn a_one_bit_change_is_not_forgiven() {
let a = img(4, 4, |_, _| [128, 128, 128, 255]);
let mut b = a.clone();
b.put_pixel(0, 0, image::Rgba([129, 128, 128, 255]));
assert!(compare(&a, &b).is_some());
}
#[test]
fn diff_image_stamps_only_the_changed_pixels() {
let a = img(4, 2, |_, _| [200, 200, 200, 255]);
let mut b = a.clone();
b.put_pixel(3, 1, image::Rgba([0, 0, 0, 255]));
let d = diff_image(&a, &b);
assert_eq!(*d.get_pixel(3, 1), image::Rgba([255, 0, 190, 255]));
assert_ne!(*d.get_pixel(0, 0), image::Rgba([255, 0, 190, 255]));
}
#[test]
fn a_missing_golden_fails() {
if blessing() {
return; }
let dir = std::env::temp_dir().join("facett-golden-missing-test");
std::fs::create_dir_all(&dir).ok();
let path = dir.join("definitely-not-here.png");
std::fs::remove_file(&path).ok();
let a = img(4, 4, |_, _| [1, 2, 3, 255]);
let err = std::panic::catch_unwind(|| assert_golden(&a, &path, "missing_probe"))
.expect_err("a missing golden must FAIL, not pass");
let msg = err.downcast_ref::<String>().map(String::as_str).unwrap_or_default();
assert!(msg.contains("MISSING golden"), "unexpected panic: {msg}");
assert!(!path.exists(), "the guard must never write the golden outside a blessing");
}
}