fenestra_shell/
testing.rs1use std::path::Path;
18
19use image::RgbaImage;
20
21const CHANNEL_TOLERANCE: u8 = 3;
23const MAX_DIFFERING_FRACTION: f64 = 0.002;
26
27pub const UPDATE_ENV: &str = "FENESTRA_UPDATE_SNAPSHOTS";
29
30pub const BUDGET_ENV: &str = "FENESTRA_SNAPSHOT_BUDGET";
33
34fn differing_budget() -> f64 {
35 std::env::var(BUDGET_ENV)
36 .ok()
37 .and_then(|v| v.parse::<f64>().ok())
38 .filter(|b| b.is_finite() && (0.0..=1.0).contains(b))
39 .unwrap_or(MAX_DIFFERING_FRACTION)
40}
41
42pub fn assert_png_snapshot(dir: impl AsRef<Path>, name: &str, actual: &RgbaImage) {
48 let dir = dir.as_ref();
49 let golden_path = dir.join(format!("{name}.png"));
50 let update = std::env::var(UPDATE_ENV).is_ok_and(|v| v == "1");
51
52 if update {
53 std::fs::create_dir_all(dir).expect("create snapshot dir");
54 actual.save(&golden_path).expect("write golden");
55 return;
56 }
57
58 let artifacts = [
59 dir.join(format!("{name}.actual.png")),
60 dir.join(format!("{name}.diff.png")),
61 dir.join(format!("{name}.side.png")),
62 ];
63
64 let golden = match image::open(&golden_path) {
65 Ok(img) => img.into_rgba8(),
66 Err(_) => panic!(
67 "missing golden {}; run with {UPDATE_ENV}=1 to create it",
68 golden_path.display()
69 ),
70 };
71
72 if golden.dimensions() != actual.dimensions() {
73 let actual_path = dir.join(format!("{name}.actual.png"));
74 actual.save(&actual_path).ok();
75 panic!(
76 "golden {} is {:?} but actual is {:?} (actual written to {})",
77 golden_path.display(),
78 golden.dimensions(),
79 actual.dimensions(),
80 actual_path.display()
81 );
82 }
83
84 let total = u64::from(golden.width()) * u64::from(golden.height());
85 let mut differing: u64 = 0;
86 let mut max_delta: u8 = 0;
87 let mut worst: (u32, u32) = (0, 0);
88 for (x, y, a) in actual.enumerate_pixels() {
89 let g = golden.get_pixel(x, y);
90 let mut pixel_exceeds = false;
91 for c in 0..4 {
92 let delta = g.0[c].abs_diff(a.0[c]);
93 if delta > max_delta {
94 max_delta = delta;
95 worst = (x, y);
96 }
97 if delta > CHANNEL_TOLERANCE {
98 pixel_exceeds = true;
99 }
100 }
101 if pixel_exceeds {
102 differing += 1;
103 }
104 }
105
106 #[expect(clippy::cast_precision_loss, reason = "image pixel counts are small")]
107 let fraction = differing as f64 / total as f64;
108 let budget = differing_budget();
109 if fraction > budget {
110 actual.save(&artifacts[0]).ok();
111 let diff = diff_image(&golden, actual);
112 diff.save(&artifacts[1]).ok();
113 side_by_side(&golden, actual, &diff)
114 .save(&artifacts[2])
115 .ok();
116 panic!(
117 "snapshot {name}: {differing}/{total} pixels ({:.3}%) exceed channel tolerance \
118 {CHANNEL_TOLERANCE}, over budget {:.3}% (max delta {max_delta} at {worst:?})\n\
119 artifacts: {name}.actual.png, {name}.diff.png (offending pixels in red), \
120 {name}.side.png — in {}\n\
121 run with {UPDATE_ENV}=1 to update",
122 fraction * 100.0,
123 budget * 100.0,
124 dir.display()
125 );
126 }
127
128 for stale in &artifacts {
130 let _ = std::fs::remove_file(stale);
131 }
132}
133
134fn diff_image(golden: &RgbaImage, actual: &RgbaImage) -> RgbaImage {
137 let mut out = RgbaImage::new(golden.width(), golden.height());
138 for (x, y, a) in actual.enumerate_pixels() {
139 let g = golden.get_pixel(x, y);
140 let exceeds = (0..4).any(|c| g.0[c].abs_diff(a.0[c]) > CHANNEL_TOLERANCE);
141 let px = if exceeds {
142 image::Rgba([255, 0, 0, 255])
143 } else {
144 image::Rgba([g.0[0] / 3, g.0[1] / 3, g.0[2] / 3, 255])
145 };
146 out.put_pixel(x, y, px);
147 }
148 out
149}
150
151fn side_by_side(golden: &RgbaImage, actual: &RgbaImage, diff: &RgbaImage) -> RgbaImage {
153 const GAP: u32 = 4;
154 let (w, h) = golden.dimensions();
155 let mut out = RgbaImage::from_pixel(w * 3 + GAP * 2, h, image::Rgba([128, 128, 128, 255]));
156 for (i, img) in [golden, actual, diff].into_iter().enumerate() {
157 #[expect(clippy::cast_possible_truncation, reason = "three panes")]
158 let x0 = (w + GAP) * i as u32;
159 for (x, y, px) in img.enumerate_pixels() {
160 out.put_pixel(x0 + x, y, *px);
161 }
162 }
163 out
164}