Skip to main content

ashlar_debug/
snapshot.rs

1//! Golden snapshots: compare a `ui.screenshot` reply with a baseline PNG.
2//!
3//! Baselines live in `tests/snapshots/<name>.png` (relative to the working directory, which is
4//! the package folder under `cargo test`; `ASHLAR_SNAPSHOT_DIR` overrides it) next to
5//! `<name>.meta.json`, which records what rendered them: `{adapter, backend, scale, width,
6//! height, tolerance?}`.
7//!
8//! - The comparison is exact unless a [`Tolerance`] is given, or recorded in the baseline's meta.
9//! - On a mismatch, `<name>-new.png` (what was rendered) and `<name>-diff.png` (the new picture
10//!   with every differing pixel magenta) are written next to the baseline, and the check fails.
11//! - `ASHLAR_UPDATE_SNAPSHOTS=1` writes the baseline instead of comparing.
12//! - A baseline rendered on another adapter or backend, or a screenshot with no GPU renderer
13//!   (grey), is skipped with a note, not failed: pixels from different GPUs differ.
14//!
15//! Egui's own test harness has a snapshot feature, but it is built on its own renderer, compares
16//! perceptually, takes no adapter metadata, and pulls the UI toolkit into this rank-4 crate; this
17//! module is small and works on any `ui.screenshot` reply, from a live app or a `Driver`.
18
19use std::path::{Path, PathBuf};
20
21use base64::Engine as _;
22use serde_json::{Value, json};
23
24/// The colour of a differing pixel in `-diff.png` (as in the UI crate's visual regression report).
25const DIFF: [u8; 4] = [255, 0, 128, 255];
26/// Largest picture decoded (bytes of RGBA).
27const MAX_BYTES: usize = 256 << 20;
28
29/// How far a screenshot may drift from its baseline and still match.
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub struct Tolerance {
32    /// A pixel differs when one of its channels is off by more than this.
33    pub channel: u8,
34    /// How many differing pixels are allowed.
35    pub pixels: usize,
36}
37
38impl Tolerance {
39    /// `channel=2,pixels=4` (either part may be left out).
40    pub fn parse(s: &str) -> Result<Tolerance, String> {
41        let mut t = Tolerance::default();
42        for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
43            let (k, v) = part.split_once('=').ok_or_else(|| format!("tolerance `{part}`: expected key=value"))?;
44            match k.trim() {
45                "channel" => t.channel = v.trim().parse().map_err(|_| format!("tolerance channel={v}: 0 to 255"))?,
46                "pixels" => t.pixels = v.trim().parse().map_err(|_| format!("tolerance pixels={v}: a count"))?,
47                other => return Err(format!("tolerance `{other}`: channel or pixels")),
48            }
49        }
50        Ok(t)
51    }
52
53    fn to_json(self) -> Value {
54        json!({"channel": self.channel, "pixels": self.pixels})
55    }
56
57    fn from_json(v: &Value) -> Option<Tolerance> {
58        let channel = u8::try_from(v.get("channel")?.as_u64()?).ok()?;
59        let pixels = usize::try_from(v.get("pixels")?.as_u64()?).ok()?;
60        Some(Tolerance { channel, pixels })
61    }
62}
63
64/// Where baselines live and how they are checked.
65#[derive(Debug, Clone, PartialEq)]
66pub struct Snapshots {
67    /// The baseline folder.
68    pub dir: PathBuf,
69    /// `None`: the baseline's recorded tolerance, or exact.
70    pub tolerance: Option<Tolerance>,
71    /// Write baselines instead of comparing.
72    pub update: bool,
73}
74
75impl Snapshots {
76    /// `tests/snapshots` (or `ASHLAR_SNAPSHOT_DIR`), updating when `ASHLAR_UPDATE_SNAPSHOTS` is
77    /// `1`, `true` or `yes`.
78    pub fn from_env() -> Snapshots {
79        let dir = std::env::var_os("ASHLAR_SNAPSHOT_DIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("tests/snapshots"));
80        let update = std::env::var("ASHLAR_UPDATE_SNAPSHOTS").is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"));
81        Snapshots { dir, tolerance: None, update }
82    }
83
84    /// Baselines in `dir`, compared exactly.
85    pub fn in_dir(dir: impl Into<PathBuf>) -> Snapshots {
86        Snapshots { dir: dir.into(), tolerance: None, update: false }
87    }
88
89    /// Check (or with `update`, record) the screenshot in `reply` as `name`.
90    ///
91    /// `reply` is a `ui.screenshot` reply: `{png, renderer?, adapter?, backend?, scale?}`.
92    pub fn check(&self, name: &str, reply: &Value) -> Result<Outcome, String> {
93        check_name(name)?;
94        let base = self.dir.join(format!("{name}.png"));
95        let meta_path = self.dir.join(format!("{name}.meta.json"));
96        let new_path = self.dir.join(format!("{name}-new.png"));
97        let diff_path = self.dir.join(format!("{name}-diff.png"));
98        let png = png_bytes(reply)?;
99        let now = Meta::of(reply);
100        if reply.get("renderer").and_then(Value::as_str) == Some("none") {
101            return Ok(Outcome::Skipped(format!("snapshot `{name}`: no GPU renderer, the screenshot is grey; not compared")));
102        }
103        let img = decode(&png)?;
104        if self.update {
105            let mut meta = now.to_json(img.width, img.height);
106            if let Some(t) = self.tolerance {
107                meta["tolerance"] = t.to_json();
108            }
109            write(&base, &png)?;
110            let text = serde_json::to_string_pretty(&meta).map_err(|e| e.to_string())?;
111            write(&meta_path, format!("{text}\n").as_bytes())?;
112            remove(&new_path);
113            remove(&diff_path);
114            return Ok(Outcome::Written(base));
115        }
116        let Ok(old_png) = std::fs::read(&base) else {
117            write(&new_path, &png)?;
118            return Err(format!(
119                "snapshot `{name}`: no baseline at {}; the screenshot is in {}. Run with ASHLAR_UPDATE_SNAPSHOTS=1 to record it",
120                base.display(),
121                new_path.display()
122            ));
123        };
124        let old_meta: Value = std::fs::read_to_string(&meta_path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(Value::Null);
125        if let Some(note) = now.other_device(&old_meta) {
126            return Ok(Outcome::Skipped(format!("snapshot `{name}`: {note}; not compared (record one here with ASHLAR_UPDATE_SNAPSHOTS=1)")));
127        }
128        let tol = self.tolerance.or_else(|| old_meta.get("tolerance").and_then(Tolerance::from_json)).unwrap_or_default();
129        let old = decode(&old_png)?;
130        let fail = |why: String| -> Result<Outcome, String> {
131            write(&new_path, &png)?;
132            let mut msg = format!("snapshot `{name}` differs from {}: {why}. New: {}", base.display(), new_path.display());
133            if old.width == img.width && old.height == img.height {
134                write(&diff_path, &encode(&diff(&old, &img, tol.channel), img.width, img.height)?)?;
135                msg.push_str(&format!(", differences (magenta): {}", diff_path.display()));
136            }
137            msg.push_str(". If the change is intended, rerun with ASHLAR_UPDATE_SNAPSHOTS=1");
138            Err(msg)
139        };
140        if (old.width, old.height) != (img.width, img.height) {
141            return fail(format!("the size is {}x{}, the baseline's {}x{}", img.width, img.height, old.width, old.height));
142        }
143        let off = differing(&old, &img, tol.channel);
144        if off > tol.pixels {
145            return fail(format!("{off} pixel(s) differ by more than {} (allowed: {})", tol.channel, tol.pixels));
146        }
147        remove(&new_path);
148        remove(&diff_path);
149        Ok(Outcome::Matched { differing: off })
150    }
151}
152
153/// What a snapshot check did.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum Outcome {
156    /// The screenshot matches the baseline (`differing` pixels within the tolerance).
157    Matched { differing: usize },
158    /// The baseline was written (update mode).
159    Written(PathBuf),
160    /// Not compared, and why (another adapter, or no GPU renderer).
161    Skipped(String),
162}
163
164impl Outcome {
165    /// One line for a person.
166    pub fn text(&self) -> String {
167        match self {
168            Outcome::Matched { differing: 0 } => "matches".into(),
169            Outcome::Matched { differing } => format!("matches ({differing} pixel(s) within tolerance)"),
170            Outcome::Written(p) => format!("wrote {}", p.display()),
171            Outcome::Skipped(note) => format!("note: {note}"),
172        }
173    }
174
175    /// For a script step's JSON result.
176    pub fn to_json(&self) -> Value {
177        match self {
178            Outcome::Matched { differing } => json!({"snapshot": "matched", "differing": differing}),
179            Outcome::Written(p) => json!({"snapshot": "written", "path": p}),
180            Outcome::Skipped(note) => json!({"snapshot": "skipped", "note": note}),
181        }
182    }
183}
184
185/// What rendered a screenshot.
186struct Meta {
187    adapter: String,
188    backend: String,
189    scale: Option<f64>,
190}
191
192impl Meta {
193    fn of(reply: &Value) -> Meta {
194        let s = |k: &str| reply.get(k).and_then(Value::as_str).unwrap_or("unknown").to_string();
195        Meta { adapter: s("adapter"), backend: s("backend"), scale: reply.get("scale").and_then(Value::as_f64) }
196    }
197
198    fn to_json(&self, width: u32, height: u32) -> Value {
199        json!({"adapter": self.adapter, "backend": self.backend, "scale": self.scale, "width": width, "height": height})
200    }
201
202    /// Why a baseline with `old` meta was made somewhere else (`None`: same device, or unknown).
203    fn other_device(&self, old: &Value) -> Option<String> {
204        let field = |k: &str| old.get(k).and_then(Value::as_str).filter(|v| *v != "unknown");
205        for (k, now) in [("adapter", &self.adapter), ("backend", &self.backend)] {
206            if let Some(then) = field(k)
207                && now != "unknown"
208                && then != now
209            {
210                return Some(format!("the baseline was rendered on {k} `{then}`, this run on `{now}`"));
211            }
212        }
213        None
214    }
215}
216
217/// Names are file names: letters, digits, `-`, `_` and `.`, not starting with `.`.
218fn check_name(name: &str) -> Result<(), String> {
219    let ok = !name.is_empty()
220        && name.len() <= 128
221        && !name.starts_with('.')
222        && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
223    if ok { Ok(()) } else { Err(format!("snapshot name `{name}`: use letters, digits, `-`, `_` and `.`")) }
224}
225
226fn png_bytes(reply: &Value) -> Result<Vec<u8>, String> {
227    let b64 = reply.get("png").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or("the reply carried no picture")?;
228    base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| format!("the picture is not base64: {e}"))
229}
230
231/// An RGBA8 picture.
232struct Rgba {
233    width: u32,
234    height: u32,
235    px: Vec<u8>,
236}
237
238fn decode(bytes: &[u8]) -> Result<Rgba, String> {
239    let mut dec = png::Decoder::new_with_limits(std::io::Cursor::new(bytes), png::Limits { bytes: MAX_BYTES });
240    dec.set_transformations(png::Transformations::normalize_to_color8());
241    let mut reader = dec.read_info().map_err(|e| format!("not a PNG: {e}"))?;
242    let size = reader.output_buffer_size().filter(|n| *n <= MAX_BYTES).ok_or("the PNG is too large")?;
243    let mut buf = vec![0; size];
244    let info = reader.next_frame(&mut buf).map_err(|e| format!("bad PNG: {e}"))?;
245    buf.truncate(info.buffer_size());
246    let px = match info.color_type {
247        png::ColorType::Rgba => buf,
248        png::ColorType::Rgb => buf.as_chunks::<3>().0.iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect(),
249        png::ColorType::GrayscaleAlpha => buf.as_chunks::<2>().0.iter().flat_map(|&[g, a]| [g, g, g, a]).collect(),
250        png::ColorType::Grayscale => buf.iter().flat_map(|g| [*g, *g, *g, 255]).collect(),
251        png::ColorType::Indexed => return Err("indexed PNGs are not supported".into()),
252    };
253    Ok(Rgba { width: info.width, height: info.height, px })
254}
255
256fn encode(px: &[u8], width: u32, height: u32) -> Result<Vec<u8>, String> {
257    let mut out = Vec::new();
258    let mut enc = png::Encoder::new(&mut out, width, height);
259    enc.set_color(png::ColorType::Rgba);
260    enc.set_depth(png::BitDepth::Eight);
261    let mut w = enc.write_header().map_err(|e| e.to_string())?;
262    w.write_image_data(px).map_err(|e| e.to_string())?;
263    w.finish().map_err(|e| e.to_string())?;
264    Ok(out)
265}
266
267fn pixel_differs(a: &[u8; 4], b: &[u8; 4], channel: u8) -> bool {
268    a.iter().zip(b).any(|(x, y)| x.abs_diff(*y) > channel)
269}
270
271fn differing(old: &Rgba, new: &Rgba, channel: u8) -> usize {
272    old.px.as_chunks::<4>().0.iter().zip(new.px.as_chunks::<4>().0).filter(|(a, b)| pixel_differs(a, b, channel)).count()
273}
274
275/// The new picture with every pixel off by more than `channel` painted [`DIFF`].
276fn diff(old: &Rgba, new: &Rgba, channel: u8) -> Vec<u8> {
277    let mut out = new.px.clone();
278    for (o, (a, b)) in out.as_chunks_mut::<4>().0.iter_mut().zip(old.px.as_chunks::<4>().0.iter().zip(new.px.as_chunks::<4>().0)) {
279        if pixel_differs(a, b, channel) {
280            o.copy_from_slice(&DIFF);
281        }
282    }
283    out
284}
285
286fn write(path: &Path, bytes: &[u8]) -> Result<(), String> {
287    if let Some(d) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
288        std::fs::create_dir_all(d).map_err(|e| format!("cannot create {}: {e}", d.display()))?;
289    }
290    std::fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))
291}
292
293/// Remove a stale `-new`/`-diff` file (missing is fine).
294fn remove(path: &Path) {
295    let _ = std::fs::remove_file(path);
296}
297
298#[cfg(test)]
299#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
300mod tests {
301    use super::*;
302
303    /// A 4x2 screenshot reply, all `colour`, with one pixel `odd` at (1, 1).
304    fn shot(colour: [u8; 4], odd: [u8; 4], adapter: &str) -> Value {
305        let mut px: Vec<u8> = (0..8).flat_map(|_| colour).collect();
306        px[20..24].copy_from_slice(&odd);
307        let png = encode(&px, 4, 2).unwrap();
308        json!({"png": base64::engine::general_purpose::STANDARD.encode(png), "renderer": "wgpu", "adapter": adapter, "backend": "Metal", "scale": 2.0})
309    }
310
311    const RED: [u8; 4] = [200, 0, 0, 255];
312
313    fn update(dir: &Path) -> Snapshots {
314        Snapshots { update: true, ..Snapshots::in_dir(dir) }
315    }
316
317    #[test]
318    fn update_writes_the_baseline_and_its_meta() {
319        let t = tempfile::tempdir().unwrap();
320        let out = update(t.path()).check("home", &shot(RED, RED, "GPU A")).unwrap();
321        assert_eq!(out, Outcome::Written(t.path().join("home.png")));
322        let meta: Value = serde_json::from_str(&std::fs::read_to_string(t.path().join("home.meta.json")).unwrap()).unwrap();
323        assert_eq!(meta, json!({"adapter": "GPU A", "backend": "Metal", "scale": 2.0, "width": 4, "height": 2}));
324        assert_eq!(Snapshots::in_dir(t.path()).check("home", &shot(RED, RED, "GPU A")).unwrap(), Outcome::Matched { differing: 0 });
325    }
326
327    #[test]
328    fn a_changed_colour_fails_and_writes_new_and_diff() {
329        let t = tempfile::tempdir().unwrap();
330        update(t.path()).check("home", &shot(RED, RED, "GPU A")).unwrap();
331        let e = Snapshots::in_dir(t.path()).check("home", &shot(RED, [0, 0, 255, 255], "GPU A")).unwrap_err();
332        assert!(e.contains("1 pixel(s) differ") && e.contains("ASHLAR_UPDATE_SNAPSHOTS=1"), "{e}");
333        let d = decode(&std::fs::read(t.path().join("home-diff.png")).unwrap()).unwrap();
334        assert_eq!(&d.px[20..24], &DIFF);
335        assert_eq!(&d.px[0..4], &RED);
336        assert!(t.path().join("home-new.png").exists());
337        // a tolerance lets it pass, and a pass clears the stale files
338        let loose = Snapshots { tolerance: Some(Tolerance { channel: 0, pixels: 1 }), ..Snapshots::in_dir(t.path()) };
339        assert_eq!(loose.check("home", &shot(RED, [0, 0, 255, 255], "GPU A")).unwrap(), Outcome::Matched { differing: 1 });
340        assert!(!t.path().join("home-diff.png").exists());
341    }
342
343    #[test]
344    fn another_adapter_or_no_gpu_is_skipped() {
345        let t = tempfile::tempdir().unwrap();
346        update(t.path()).check("home", &shot(RED, RED, "GPU A")).unwrap();
347        let out = Snapshots::in_dir(t.path()).check("home", &shot([0, 0, 0, 255], RED, "GPU B")).unwrap();
348        assert!(matches!(&out, Outcome::Skipped(n) if n.contains("GPU A") && n.contains("GPU B")), "{out:?}");
349        let mut grey = shot(RED, RED, "GPU A");
350        grey["renderer"] = json!("none");
351        assert!(matches!(Snapshots::in_dir(t.path()).check("home", &grey).unwrap(), Outcome::Skipped(_)));
352    }
353
354    #[test]
355    fn missing_baselines_sizes_and_names_fail_clearly() {
356        let t = tempfile::tempdir().unwrap();
357        let e = Snapshots::in_dir(t.path()).check("none", &shot(RED, RED, "GPU A")).unwrap_err();
358        assert!(e.contains("no baseline"), "{e}");
359        assert!(Snapshots::in_dir(t.path()).check("../up", &shot(RED, RED, "GPU A")).is_err());
360        let tiny = encode(&RED, 1, 1).unwrap();
361        std::fs::write(t.path().join("small.png"), tiny).unwrap();
362        let e = Snapshots::in_dir(t.path()).check("small", &shot(RED, RED, "GPU A")).unwrap_err();
363        assert!(e.contains("4x2") && e.contains("1x1"), "{e}");
364    }
365
366    #[test]
367    fn tolerances_parse_and_are_recorded() {
368        assert_eq!(Tolerance::parse("channel=2,pixels=4").unwrap(), Tolerance { channel: 2, pixels: 4 });
369        assert_eq!(Tolerance::parse("pixels=9").unwrap(), Tolerance { channel: 0, pixels: 9 });
370        assert!(Tolerance::parse("channel=300").is_err());
371        assert!(Tolerance::parse("colour=1").is_err());
372        let t = tempfile::tempdir().unwrap();
373        let rec = Snapshots { tolerance: Some(Tolerance { channel: 0, pixels: 1 }), ..update(t.path()) };
374        rec.check("home", &shot(RED, RED, "GPU A")).unwrap();
375        // the recorded tolerance applies when the check names none
376        assert_eq!(Snapshots::in_dir(t.path()).check("home", &shot(RED, [1, 2, 3, 255], "GPU A")).unwrap(), Outcome::Matched { differing: 1 });
377    }
378}