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 three artifacts land next to the
7//! golden: `<name>.actual.png` (what rendered), `<name>.diff.png` (the
8//! offending pixels in red over the dimmed golden), and
9//! `<name>.side.png` (golden | actual | diff side by side) — read the
10//! diff first; it shows *where*, not just *how much*.
11//!
12//! Goldens are rendered on macOS/Metal; a software rasterizer (CI's
13//! lavapipe) antialiases slightly differently, so the pixel budget can be
14//! widened there with `FENESTRA_SNAPSHOT_BUDGET` (e.g. `0.006`) without
15//! loosening the reference platform.
16
17use std::path::Path;
18
19use fenestra_core::{TextSize, Theme, col, div, image_rgba8, row, text};
20use image::RgbaImage;
21
22use crate::{render_element, with_headless};
23
24/// Per-channel delta at or below this is identical enough.
25const CHANNEL_TOLERANCE: u8 = 3;
26/// Fraction of pixels allowed to exceed the channel tolerance (default;
27/// see [`BUDGET_ENV`]).
28const MAX_DIFFERING_FRACTION: f64 = 0.002;
29
30/// Env var that regenerates goldens instead of comparing.
31pub const UPDATE_ENV: &str = "FENESTRA_UPDATE_SNAPSHOTS";
32
33/// Env var overriding the differing-pixel budget (a fraction, e.g.
34/// `0.006`), for runners whose rasterizer differs from the goldens'.
35pub const BUDGET_ENV: &str = "FENESTRA_SNAPSHOT_BUDGET";
36
37fn differing_budget() -> f64 {
38    std::env::var(BUDGET_ENV)
39        .ok()
40        .and_then(|v| v.parse::<f64>().ok())
41        .filter(|b| b.is_finite() && (0.0..=1.0).contains(b))
42        .unwrap_or(MAX_DIFFERING_FRACTION)
43}
44
45/// Compares `actual` against the golden `dir/name.png`.
46///
47/// # Panics
48/// On size or content mismatch beyond tolerance, or when the golden is
49/// missing and `FENESTRA_UPDATE_SNAPSHOTS=1` is not set.
50pub fn assert_png_snapshot(dir: impl AsRef<Path>, name: &str, actual: &RgbaImage) {
51    let dir = dir.as_ref();
52    let golden_path = dir.join(format!("{name}.png"));
53    let update = std::env::var(UPDATE_ENV).is_ok_and(|v| v == "1");
54
55    if update {
56        std::fs::create_dir_all(dir).expect("create snapshot dir");
57        actual.save(&golden_path).expect("write golden");
58        return;
59    }
60
61    let artifacts = [
62        dir.join(format!("{name}.actual.png")),
63        dir.join(format!("{name}.diff.png")),
64        dir.join(format!("{name}.side.png")),
65    ];
66
67    let golden = match image::open(&golden_path) {
68        Ok(img) => img.into_rgba8(),
69        Err(_) => panic!(
70            "missing golden {}; run with {UPDATE_ENV}=1 to create it",
71            golden_path.display()
72        ),
73    };
74
75    if golden.dimensions() != actual.dimensions() {
76        let actual_path = dir.join(format!("{name}.actual.png"));
77        actual.save(&actual_path).ok();
78        panic!(
79            "golden {} is {:?} but actual is {:?} (actual written to {})",
80            golden_path.display(),
81            golden.dimensions(),
82            actual.dimensions(),
83            actual_path.display()
84        );
85    }
86
87    let total = u64::from(golden.width()) * u64::from(golden.height());
88    let mut differing: u64 = 0;
89    let mut max_delta: u8 = 0;
90    let mut worst: (u32, u32) = (0, 0);
91    for (x, y, a) in actual.enumerate_pixels() {
92        let g = golden.get_pixel(x, y);
93        let mut pixel_exceeds = false;
94        for c in 0..4 {
95            let delta = g.0[c].abs_diff(a.0[c]);
96            if delta > max_delta {
97                max_delta = delta;
98                worst = (x, y);
99            }
100            if delta > CHANNEL_TOLERANCE {
101                pixel_exceeds = true;
102            }
103        }
104        if pixel_exceeds {
105            differing += 1;
106        }
107    }
108
109    #[expect(clippy::cast_precision_loss, reason = "image pixel counts are small")]
110    let fraction = differing as f64 / total as f64;
111    let budget = differing_budget();
112    if fraction > budget {
113        actual.save(&artifacts[0]).ok();
114        let diff = diff_image(&golden, actual);
115        diff.save(&artifacts[1]).ok();
116        side_by_side(&golden, actual, &diff)
117            .save(&artifacts[2])
118            .ok();
119        panic!(
120            "snapshot {name}: {differing}/{total} pixels ({:.3}%) exceed channel tolerance \
121             {CHANNEL_TOLERANCE}, over budget {:.3}% (max delta {max_delta} at {worst:?})\n\
122             artifacts: {name}.actual.png, {name}.diff.png (offending pixels in red), \
123             {name}.side.png — in {}\n\
124             run with {UPDATE_ENV}=1 to update",
125            fraction * 100.0,
126            budget * 100.0,
127            dir.display()
128        );
129    }
130
131    // Passed: remove stale failure artifacts from earlier runs.
132    for stale in &artifacts {
133        let _ = std::fs::remove_file(stale);
134    }
135}
136
137/// Per-cell scale bounds ([`clamp_strip_scale`]): below the floor a frame is
138/// unreadably small; above 1.0 there is nothing to upscale — a filmstrip
139/// never carries more detail than the frames it tiles.
140pub const MIN_STRIP_SCALE: f32 = 0.05;
141/// See [`MIN_STRIP_SCALE`].
142pub const MAX_STRIP_SCALE: f32 = 1.0;
143
144/// Gap between cells and around the strip's edges, and the caption's height
145/// budget below each thumbnail (logical px, at strip scale — the caption
146/// itself is not scaled, so it stays legible even in a shrunk strip).
147const STRIP_GAP: f32 = 8.0;
148const STRIP_CAPTION_H: f32 = 18.0;
149/// Gap between a thumbnail and its caption within one cell — shared by the
150/// pre-render size check and the actual element tree, so they can't drift
151/// apart.
152const THUMB_CAPTION_GAP: f32 = 2.0;
153
154/// What can go wrong composing a filmstrip via [`filmstrip_image`]: never a
155/// panic, always one of these.
156#[derive(Debug)]
157pub enum FilmstripError {
158    /// `frames` was empty — there is nothing to compose.
159    NoFrames,
160    /// The composed strip would exceed the headless renderer's maximum
161    /// texture dimension `max_dim`; `width`/`height` is the strip size that
162    /// would have been needed.
163    TooLarge {
164        /// The strip width that would have been needed.
165        width: u32,
166        /// The strip height that would have been needed.
167        height: u32,
168        /// The renderer's actual texture dimension ceiling.
169        max_dim: u32,
170    },
171}
172
173impl std::fmt::Display for FilmstripError {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        match self {
176            Self::NoFrames => write!(f, "no frames captured"),
177            Self::TooLarge {
178                width,
179                height,
180                max_dim,
181            } => write!(
182                f,
183                "strip would be {width}x{height}px, over the renderer's {max_dim}px limit; \
184                 lower `scale` or capture fewer frames"
185            ),
186        }
187    }
188}
189
190impl std::error::Error for FilmstripError {}
191
192/// Clamps a filmstrip's per-cell `scale` into the composable range
193/// (`0.05..=1.0`, see [`MIN_STRIP_SCALE`]/[`MAX_STRIP_SCALE`]); non-finite
194/// input clamps to the default (no shrink), same as a `NaN` or infinite
195/// input would from any other caller-supplied dimension in this crate.
196#[must_use]
197pub fn clamp_strip_scale(scale: f32) -> f32 {
198    if scale.is_finite() {
199        scale.clamp(MIN_STRIP_SCALE, MAX_STRIP_SCALE)
200    } else {
201        MAX_STRIP_SCALE
202    }
203}
204
205/// Composes frames captured by [`crate::Harness::film`] into one horizontal
206/// filmstrip: each frame side by side, left to right, captioned with its
207/// index and elapsed time (`index * interval_ms`, matching the
208/// `interval_ms` passed to `film`). `scale` (per-cell, clamped via
209/// [`clamp_strip_scale`]) shrinks each thumbnail so a strip of many, or
210/// large, frames still composes to a reviewable size.
211///
212/// The strip's own chrome — background, cell borders, caption text — always
213/// renders under [`Theme::light`], independent of whichever theme produced
214/// the frames: only the pixels *inside* each cell vary, so the strip is a
215/// stable presentation around them, not a second thing under test.
216///
217/// Never panics: [`assert_filmstrip_snapshot`]/[`assert_filmstrip_snapshot_scaled`]
218/// call this internally and panic on `Err` (a golden-test misuse); any other
219/// caller (e.g. an agent-facing render) gets a path-pointed error instead.
220///
221/// # Errors
222/// See [`FilmstripError`].
223pub fn filmstrip_image(
224    frames: &[RgbaImage],
225    interval_ms: u64,
226    scale: f32,
227) -> Result<RgbaImage, FilmstripError> {
228    if frames.is_empty() {
229        return Err(FilmstripError::NoFrames);
230    }
231    let scale = clamp_strip_scale(scale);
232
233    #[expect(
234        clippy::cast_precision_loss,
235        reason = "frame pixel dimensions are far below f32's exact-integer range"
236    )]
237    let cells: Vec<(u32, u32, f32, f32)> = frames
238        .iter()
239        .map(|f| {
240            let (w, h) = f.dimensions();
241            (w, h, w as f32 * scale, h as f32 * scale)
242        })
243        .collect();
244
245    let strip_w = STRIP_GAP
246        + cells
247            .iter()
248            .map(|&(_, _, tw, _)| tw + STRIP_GAP)
249            .sum::<f32>();
250    let strip_h = STRIP_GAP * 2.0
251        + cells.iter().map(|&(_, _, _, th)| th).fold(0.0f32, f32::max)
252        + THUMB_CAPTION_GAP
253        + STRIP_CAPTION_H;
254
255    let max_dim = with_headless(|h| h.max_dimension()).unwrap_or(8192);
256    #[expect(
257        clippy::cast_possible_truncation,
258        clippy::cast_sign_loss,
259        reason = "strip_w/strip_h are sums of small positive cell sizes"
260    )]
261    let (strip_w, strip_h) = (strip_w.ceil() as u32, strip_h.ceil() as u32);
262    if strip_w > max_dim || strip_h > max_dim {
263        return Err(FilmstripError::TooLarge {
264            width: strip_w,
265            height: strip_h,
266            max_dim,
267        });
268    }
269
270    let mut strip = row::<()>().p(STRIP_GAP).gap(STRIP_GAP).items_start();
271    for (i, (frame, &(w, h, tw, th))) in frames.iter().zip(&cells).enumerate() {
272        let at_ms = (i as u64).saturating_mul(interval_ms);
273        let thumb = div::<()>()
274            .child(image_rgba8(w, h, frame.as_raw().clone()).w(tw).h(th))
275            .themed(|t, s| s.border(1.0, t.border_subtle));
276        let cell = col::<()>()
277            .gap(THUMB_CAPTION_GAP)
278            .items_center()
279            .child(thumb)
280            .child(
281                text(format!("f{i:03} +{at_ms}ms"))
282                    .size(TextSize::Xs)
283                    .mono()
284                    .themed(|t, s| s.color(t.text_muted)),
285            );
286        strip = strip.child(cell);
287    }
288
289    Ok(render_element(strip, &Theme::light(), (strip_w, strip_h)))
290}
291
292/// Composes frames captured by [`crate::Harness::film`] into one horizontal
293/// filmstrip and compares it against the PNG golden `dir/name.png` exactly
294/// like [`assert_png_snapshot`] (same env vars, same
295/// `.actual`/`.diff`/`.side` failure artifacts). See [`filmstrip_image`] for
296/// how the strip is composed.
297///
298/// This only proves the pixels a filmstrip carries render into a stable
299/// strip; it says nothing about whether the frames it was given actually
300/// show motion (a filmstrip of `frames` identical copies — reduced motion
301/// left on, see [`crate::Harness::film`] — golden-tests just as cleanly as a
302/// real transition would).
303///
304/// # Panics
305/// If `frames` is empty, the composed strip would exceed the headless
306/// renderer's maximum texture dimension (capture fewer frames or use
307/// [`assert_filmstrip_snapshot_scaled`] to shrink each cell), or the golden
308/// comparison fails.
309pub fn assert_filmstrip_snapshot(
310    dir: impl AsRef<Path>,
311    name: &str,
312    frames: &[RgbaImage],
313    interval_ms: u64,
314) {
315    assert_filmstrip_snapshot_scaled(dir, name, frames, interval_ms, MAX_STRIP_SCALE);
316}
317
318/// Like [`assert_filmstrip_snapshot`], with an explicit per-cell `scale`
319/// (clamped to `0.05..=1.0`) so a strip of many, or large, frames still
320/// makes a small, reviewable golden.
321///
322/// # Panics
323/// Same as [`assert_filmstrip_snapshot`].
324pub fn assert_filmstrip_snapshot_scaled(
325    dir: impl AsRef<Path>,
326    name: &str,
327    frames: &[RgbaImage],
328    interval_ms: u64,
329    scale: f32,
330) {
331    let composed = filmstrip_image(frames, interval_ms, scale)
332        .unwrap_or_else(|e| panic!("assert_filmstrip_snapshot {name}: {e}"));
333    assert_png_snapshot(dir, name, &composed);
334}
335
336/// The offending pixels in solid red over the golden dimmed to a third —
337/// it shows *where* the images disagree at a glance.
338fn diff_image(golden: &RgbaImage, actual: &RgbaImage) -> RgbaImage {
339    let mut out = RgbaImage::new(golden.width(), golden.height());
340    for (x, y, a) in actual.enumerate_pixels() {
341        let g = golden.get_pixel(x, y);
342        let exceeds = (0..4).any(|c| g.0[c].abs_diff(a.0[c]) > CHANNEL_TOLERANCE);
343        let px = if exceeds {
344            image::Rgba([255, 0, 0, 255])
345        } else {
346            image::Rgba([g.0[0] / 3, g.0[1] / 3, g.0[2] / 3, 255])
347        };
348        out.put_pixel(x, y, px);
349    }
350    out
351}
352
353/// Golden | actual | diff in one strip, separated by 4px dividers.
354fn side_by_side(golden: &RgbaImage, actual: &RgbaImage, diff: &RgbaImage) -> RgbaImage {
355    const GAP: u32 = 4;
356    let (w, h) = golden.dimensions();
357    let mut out = RgbaImage::from_pixel(w * 3 + GAP * 2, h, image::Rgba([128, 128, 128, 255]));
358    for (i, img) in [golden, actual, diff].into_iter().enumerate() {
359        #[expect(clippy::cast_possible_truncation, reason = "three panes")]
360        let x0 = (w + GAP) * i as u32;
361        for (x, y, px) in img.enumerate_pixels() {
362            out.put_pixel(x0 + x, y, *px);
363        }
364    }
365    out
366}