Skip to main content

fenestra_shell/
testing.rs

1//! PNG golden testing: tolerance-based image comparison with an update
2//! mode, used by every visual test in the workspace.
3//!
4//! Comparison passes when every channel delta is at most 3/255 and fewer
5//! than 0.2 percent of pixels exceed that. `FENESTRA_UPDATE_SNAPSHOTS=1`
6//! regenerates goldens. On failure the actual image is written next to the
7//! golden as `<name>.actual.png` for inspection.
8//!
9//! Goldens are rendered on macOS/Metal; a software rasterizer (CI's
10//! lavapipe) antialiases slightly differently, so the pixel budget can be
11//! widened there with `FENESTRA_SNAPSHOT_BUDGET` (e.g. `0.006`) without
12//! loosening the reference platform.
13
14use std::path::Path;
15
16use image::RgbaImage;
17
18/// Per-channel delta at or below this is identical enough.
19const CHANNEL_TOLERANCE: u8 = 3;
20/// Fraction of pixels allowed to exceed the channel tolerance (default;
21/// see [`BUDGET_ENV`]).
22const MAX_DIFFERING_FRACTION: f64 = 0.002;
23
24/// Env var that regenerates goldens instead of comparing.
25pub const UPDATE_ENV: &str = "FENESTRA_UPDATE_SNAPSHOTS";
26
27/// Env var overriding the differing-pixel budget (a fraction, e.g.
28/// `0.006`), for runners whose rasterizer differs from the goldens'.
29pub const BUDGET_ENV: &str = "FENESTRA_SNAPSHOT_BUDGET";
30
31fn differing_budget() -> f64 {
32    std::env::var(BUDGET_ENV)
33        .ok()
34        .and_then(|v| v.parse::<f64>().ok())
35        .filter(|b| b.is_finite() && (0.0..=1.0).contains(b))
36        .unwrap_or(MAX_DIFFERING_FRACTION)
37}
38
39/// Compares `actual` against the golden `dir/name.png`.
40///
41/// # Panics
42/// On size or content mismatch beyond tolerance, or when the golden is
43/// missing and `FENESTRA_UPDATE_SNAPSHOTS=1` is not set.
44pub fn assert_png_snapshot(dir: impl AsRef<Path>, name: &str, actual: &RgbaImage) {
45    let dir = dir.as_ref();
46    let golden_path = dir.join(format!("{name}.png"));
47    let update = std::env::var(UPDATE_ENV).is_ok_and(|v| v == "1");
48
49    if update {
50        std::fs::create_dir_all(dir).expect("create snapshot dir");
51        actual.save(&golden_path).expect("write golden");
52        return;
53    }
54
55    let golden = match image::open(&golden_path) {
56        Ok(img) => img.into_rgba8(),
57        Err(_) => panic!(
58            "missing golden {}; run with {UPDATE_ENV}=1 to create it",
59            golden_path.display()
60        ),
61    };
62
63    if golden.dimensions() != actual.dimensions() {
64        let actual_path = dir.join(format!("{name}.actual.png"));
65        actual.save(&actual_path).ok();
66        panic!(
67            "golden {} is {:?} but actual is {:?} (actual written to {})",
68            golden_path.display(),
69            golden.dimensions(),
70            actual.dimensions(),
71            actual_path.display()
72        );
73    }
74
75    let total = u64::from(golden.width()) * u64::from(golden.height());
76    let mut differing: u64 = 0;
77    let mut max_delta: u8 = 0;
78    for (g, a) in golden.pixels().zip(actual.pixels()) {
79        let mut pixel_exceeds = false;
80        for c in 0..4 {
81            let delta = g.0[c].abs_diff(a.0[c]);
82            max_delta = max_delta.max(delta);
83            if delta > CHANNEL_TOLERANCE {
84                pixel_exceeds = true;
85            }
86        }
87        if pixel_exceeds {
88            differing += 1;
89        }
90    }
91
92    #[expect(clippy::cast_precision_loss, reason = "image pixel counts are small")]
93    let fraction = differing as f64 / total as f64;
94    if fraction > differing_budget() {
95        let actual_path = dir.join(format!("{name}.actual.png"));
96        actual.save(&actual_path).ok();
97        panic!(
98            "snapshot {name}: {differing}/{total} pixels ({:.3}%) exceed channel tolerance \
99             {CHANNEL_TOLERANCE} (max delta {max_delta}); actual written to {} — \
100             run with {UPDATE_ENV}=1 to update",
101            fraction * 100.0,
102            actual_path.display()
103        );
104    }
105}