facett-core/src/golden.rs
//! **golden** — the shared GOLDEN-IMAGE GUARD for facett's screenshot proofs.
//!
//! A screenshot test that renders a PNG, **overwrites the committed golden** and
//! then asserts only "it is bigger than 5 KB" is not a test. It is a write-only
//! file: it can never go red, and it silently absorbs every visual regression that
//! lands on top of it. facett shipped exactly that shape until 2026-07-23 — and it
//! hid a deleted `facett-grid` toolbar (45 % of the flagship golden's pixels) for
//! three weeks.
//!
//! This module is the guard those tests were missing. [`assert_golden`] **compares**
//! the freshly rendered image against the committed PNG and does not write it:
//!
//! | situation | result |
//! |------------------------------|-----------------------------------------------------------|
//! | identical render | **pass**, golden untouched (a clean `git status`) |
//! | render differs | **FAIL** — pixel count + %, `actual`/`diff` PNGs in `target/` |
//! | different size | **FAIL** — expected vs actual dimensions |
//! | **golden missing** | **FAIL** — never a silent pass on nothing |
//! | `UPDATE_SNAPSHOTS=1` | golden (re-)written, test passes, "BLESSED" on stderr |
//!
//! The missing-golden row is the important one. The classic way to build a guard
//! that guards nothing is to compare snapshots but treat "no snapshot yet" as
//! success (or to assert on the *error text* of the missing-file case): the suite is
//! then structurally green over an empty directory. A missing golden here is a
//! failure, full stop — the only way to create one is the explicit opt-in below.
//!
//! ## Exact match, no tolerance
//!
//! The comparison is **byte-exact per channel**. facett's `egui_kittest` + wgpu
//! renders are deterministic on a fixed host: two consecutive runs of the same
//! source produce byte-identical PNGs (verified before this guard was trusted).
//! No anti-aliasing/font-jitter tolerance is configured, because none is needed and
//! a tolerance is exactly the knob that lets a real regression through. If a future
//! host (a different GPU / driver) does jitter, add the smallest tolerance that
//! makes *that* host stable and say so at the call site — do not widen it globally.
//!
//! ## Blessing
//!
//! Opt-in, never the default:
//!
//! ```text
//! UPDATE_SNAPSHOTS=1 cargo test -p facett-grid --test snapshot
//! ```
//!
//! Accepted values: `1` / `true` / `yes` / `on` (anything else is off; an
//! unrecognised value panics rather than silently testing). Blessing a golden is a
//! reviewable act: the PNG is git-lfs, so the diff shows up as a new LFS object and
//! the commit message has to say *why* the pixels moved.
use std::path::{Path, PathBuf};
use image::RgbaImage;
/// Where the failure artifacts (`*.actual.png`, `*.diff.png`) are written, so a
/// human can look at them without dirtying the git tree. Under `target/` on
/// purpose: the golden directories are git-lfs and have been a recurring source of
/// accidental staging noise.
fn artifact_dir() -> PathBuf {
if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") {
return PathBuf::from(dir).join("snapshot-failures");
}
// `current_exe` is `<target>/<profile>/deps/<test-bin>`; walk up to `target`.
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");
}
}
}
// Fallback: this crate sits one level under the workspace root.
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or_else(|| Path::new("."))
.join("target")
.join("snapshot-failures")
}
/// `<target>/snapshot-failures/<name>.<kind>.png`, with any `<name>` subdirectory
/// (the palette gallery names its shots `palette/facett_demo_*`) created.
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
}
/// Is the blessing opt-in switched on? Reads `UPDATE_SNAPSHOTS`.
///
/// # Panics
/// On an unrecognised value — a typo (`UPDATE_SNAPSHOTS=yess`) must not quietly
/// leave the guard armed *or* quietly disarm it.
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"
),
},
}
}
/// The outcome of one golden comparison.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
/// Every pixel matched.
Match,
/// The golden was (re-)written because `UPDATE_SNAPSHOTS` is set.
Blessed,
}
/// A structured difference between two same-sized images.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Diff {
/// Number of pixels differing in any channel.
pub pixels: u64,
/// Total pixels compared.
pub total: u64,
/// The first differing pixel in raster order, `(x, y)`.
pub first: Option<(u32, u32)>,
/// Bounding box of all differing pixels, `(x0, y0, x1, y1)` inclusive.
pub bbox: Option<(u32, u32, u32, u32)>,
/// The largest absolute per-channel delta seen.
pub max_channel_delta: u8,
}
impl Diff {
/// Differing pixels as a percentage of the frame.
pub fn percent(&self) -> f64 {
if self.total == 0 { 0.0 } else { self.pixels as f64 * 100.0 / self.total as f64 }
}
}
/// Compare two same-sized RGBA images pixel-exactly. `None` = identical.
///
/// Pure (no I/O), so it is unit-testable and reusable by any facett render proof
/// that already holds both buffers.
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)
}
/// The floor a **"the picture changed"** proof has to clear.
///
/// [`compare`] answers *did anything differ*. That is the right question for a golden,
/// and the WRONG one for a before/after interaction proof — which is why every robot
/// suite that copied the reference harness wrote `assert_ne!(png_bytes_a, png_bytes_b)`
/// and got a test that cannot fail for the reason it claims.
///
/// Three separate suites were measured passing that assertion on things the click never
/// caused: **a window title**, **an ephemeral port number rendered as text**, and **a
/// text legend**. Each moves a handful of pixels in a thin strip, and "the bytes differ"
/// cannot tell that from a pane redrawing.
///
/// So a real proof needs two floors, and `percent` alone is not enough:
///
/// * **`percent`** — the share of the frame that must differ. Rules out a port number,
/// a frame counter, a few characters of text.
/// * **`extent`** — the changed region's bounding box must span at least this share of
/// the frame in **both** axes. Rules out a title bar or a status line: wide, but a
/// thin strip. A pane that actually redrew has extent in both directions.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MinChange {
/// Minimum share of differing pixels, in percent of the whole frame.
pub percent: f64,
/// Minimum bbox width AND height, each as a share (0.0–1.0) of the frame.
pub extent: f64,
}
impl MinChange {
/// A pane or panel visibly redrew — the usual "did my click reach the pixels" case.
///
/// 0.5 % of a 1100×760 frame is ~4 200 pixels, and the box must span 10 % of the
/// frame each way. A one-line text change cannot reach either number.
pub const PANE: Self = Self { percent: 0.5, extent: 0.10 };
/// A smaller widget changed (a toggle, a single row). Still far above text noise.
pub const WIDGET: Self = Self { percent: 0.05, extent: 0.02 };
}
/// Assert that `after` differs from `before` **substantially enough to be the change
/// you meant**, returning the [`Diff`] on success and a named reason on failure.
///
/// This is the ONE writer for before/after interaction proofs. Use it instead of
/// comparing encoded PNG bytes: those carry metadata and encoder state, so
/// `assert_ne!` on them can pass while the picture is identical, and passes trivially on
/// any incidental text.
///
/// # Errors
/// Names which floor failed and by how much, so a failure is actionable without
/// re-running under a debugger:
/// * the two frames are identical;
/// * differing pixels below `min.percent`;
/// * the changed region is a thin strip (below `min.extent` in either axis);
/// * the frames are different sizes (a resize is not a proof that a click landed).
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
));
}
// `compare` guarantees a bbox whenever pixels > 0.
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)
}
/// **The control every before/after pixel proof has to run FIRST**: two renders of
/// the *same* state must be byte-identical.
///
/// [`moved`] answers "did the picture change enough". On its own that is still half a
/// proof, because it cannot distinguish *the interaction* from *the host*. If a render
/// is not reproducible — driver dithering, an unpinned clock, a frame counter, an
/// animation still easing — then a `moved` pass may be measuring jitter, and no floor
/// chosen without measuring that jitter is a considered number.
///
/// So the shape is: render, render again with **no input in between**, assert `stable`
/// (the measured NOISE FLOOR, which must be 0 px), *then* interact and assert
/// [`moved`]. A `moved` figure is only meaningful next to the noise floor it clears.
///
/// STINKY-19 R0 named this control as one of four legs of the fix pattern; the other
/// three (raw RGBA, a percent floor, an extent floor) live in [`compare`] and [`moved`].
/// It is here rather than in each suite so the fourth leg is not re-derived — badly —
/// a fifth time.
///
/// # Errors
/// Names the finding, the amount of jitter and where it is, so the failure says
/// "this host is not deterministic" instead of "images differ".
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
))
}
}
}
/// Paint a human-readable diff image: the golden dimmed to grayscale, with every
/// differing pixel stamped magenta. Reads at a glance as "here is what moved".
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; // dimmed, so the stamps pop
image::Rgba([l, l, l, 255])
} else {
image::Rgba([255, 0, 190, 255])
};
}
out
}
/// **The guard.** Compare `actual` against the committed golden PNG at `golden_path`.
///
/// `name` is only used to label the failure artifacts and the panic message.
///
/// See the [module docs](self) for the full behaviour table. Returns the
/// [`Verdict`] on success; **panics** on any mismatch, size change, or missing
/// golden.
///
/// # Panics
/// - the golden file does not exist, or cannot be decoded (a bare git-lfs pointer
/// is the usual cause — run `git lfs pull`)
/// - the rendered size differs from the golden's
/// - any pixel differs
#[track_caller]
pub fn assert_golden(actual: &RgbaImage, golden_path: &Path, name: &str) -> Verdict {
assert_golden_within(actual, golden_path, name, 0)
}
/// [`assert_golden`] for a render that measurably **jitters by driver rounding** on a
/// host it was not blessed on: a diff passes when NO channel of ANY pixel moved by more
/// than `max_channel_delta`. `0` is [`assert_golden`] exactly.
///
/// This is the module rule "add the smallest tolerance that makes *that* host stable and
/// say so at the call site", not a global widening: every call site passes its own
/// number with the measurement that justifies it. A tolerance of a couple of levels
/// cannot absorb a structural change — a removed toolbar, a missing label, a wrong fill
/// moves channels by tens to hundreds — and the diff count and region are still printed
/// when it passes, so an accumulating drift stays visible.
///
/// # Panics
/// As [`assert_golden`], except that a diff within `max_channel_delta` passes.
#[track_caller]
pub fn assert_golden_within(actual: &RgbaImage, golden_path: &Path, name: &str, max_channel_delta: u8) -> 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;
}
// Dump the render next to the failure evidence and return the panic message.
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).filter(|d| {
if max_channel_delta > 0 && d.max_channel_delta <= max_channel_delta {
eprintln!(
"golden OK {name} WITHIN TOLERANCE: {} of {} pixels ({:.2}%) differ by at most {} (allowed {max_channel_delta}) ← {}",
d.pixels,
d.total,
d.percent(),
d.max_channel_delta,
golden_path.display()
);
false
} else {
true
}
}) {
// Write the diff image beside the actual render before panicking.
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(),
));
}
let how = if max_channel_delta == 0 { "exact".to_string() } else { format!("within {max_channel_delta}") };
eprintln!("golden OK {name} ({aw}×{ah}, {how}) ← {}", 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)))
}
/// The tolerance passes rounding jitter up to its bound and nothing past it: a
/// delta-2 speckle over a quarter of the frame passes at 2, fails at 1 and at 0
/// (plain `assert_golden`), and a single pixel off by 3 fails at 2.
#[test]
fn a_channel_tolerance_forgives_rounding_and_nothing_bigger() {
if blessing() {
return; // a blessing run rewrites goldens; this test writes its own
}
let dir = std::env::temp_dir().join(format!("facett-golden-tol-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("g.png");
let base = img(16, 16, |x, y| [(x * 9) as u8, (y * 9) as u8, 100, 255]);
base.save(&path).unwrap();
let jitter = img(16, 16, |x, y| {
let d = u8::from(x % 2 == 0 && y % 2 == 0) * 2;
[(x * 9) as u8 + d, (y * 9) as u8, 100, 255]
});
assert_eq!(assert_golden_within(&jitter, &path, "tol-2", 2), Verdict::Match);
let one = std::panic::catch_unwind(|| assert_golden_within(&jitter, &path, "tol-1", 1));
assert!(one.is_err(), "a delta-2 diff must fail a tolerance of 1");
let exact = std::panic::catch_unwind(|| assert_golden(&jitter, &path, "tol-0"));
assert!(exact.is_err(), "and plain assert_golden stays exact");
let mut spike = base.clone();
spike.get_pixel_mut(7, 7).0[2] = 103;
let three = std::panic::catch_unwind(|| assert_golden_within(&spike, &path, "tol-2-spike", 2));
assert!(three.is_err(), "one pixel off by 3 must fail a tolerance of 2");
std::fs::remove_dir_all(&dir).ok();
}
/// **`moved` must REJECT the three shapes that were measured passing
/// `assert_ne!(png_a, png_b)` in three independent robot suites.**
///
/// This is the point of the whole predicate, so it is asserted directly rather than
/// left to a comment. Each case below is a real observed false pass.
#[test]
fn moved_rejects_the_incidental_changes_that_fooled_assert_ne() {
let base = img(1100, 760, |_, _| [30, 30, 40, 255]);
// 1. A WINDOW TITLE / status line: wide, but ~18 px tall.
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}");
// 2. An EPHEMERAL PORT rendered as text: a few dozen pixels somewhere.
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}");
// 3. A LEGEND: a small block, still nothing like a pane.
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");
// And it must ACCEPT a pane actually redrawing — otherwise the guard is simply
// "always red", which is no more useful than "always green".
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());
}
/// Identical frames are the original failure the `assert_ne!` was reaching for, and
/// the message has to say so rather than reporting a floor.
#[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}");
}
/// A resize is not proof that an interaction landed, and must not be silently
/// compared (the old code would have compared different-length byte vectors and
/// happily reported "they differ").
#[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}");
}
/// **The determinism control must itself be able to go red.**
///
/// A control that only ever returns `Ok` is the exact disease it exists to catch:
/// the suite would print "noise floor 0 px" whatever the host did. So both
/// directions are asserted — a reproducible pair passes, and a single jittering
/// pixel is REFUSED and named.
#[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");
// One channel, one bit, one pixel — 0.0001 % of the frame. `moved` would call
// this "below the floor"; the control has to call it NOT DETERMINISTIC.
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}");
}
/// A same-state pair that comes back at a different SIZE is a broken harness, not a
/// comparison to attempt — and it must not be reported as a mere difference.
#[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);
}
/// The guard is EXACT: a single least-significant-bit change must not be
/// forgiven. This is the assertion a tolerance would break.
#[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; // under an explicit bless run, "missing" is legitimately created
}
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}");
// …and it must not have created the golden as a side effect.
assert!(!path.exists(), "the guard must never write the golden outside a blessing");
}
}