1use std::path::{Path, PathBuf};
8
9use base64::Engine;
10
11use crate::error::TestError;
12
13#[derive(Debug)]
15pub struct VisualDiff {
16 pub match_percentage: f64,
18 pub diff_pixel_count: usize,
20 pub total_pixels: usize,
22 pub diff_image_path: Option<PathBuf>,
24}
25
26impl VisualDiff {
27 #[must_use]
29 pub fn is_match(&self, threshold_percent: f64) -> bool {
30 self.match_percentage >= (100.0 - threshold_percent)
31 }
32}
33
34#[derive(Debug, Clone)]
36pub struct VisualOptions {
37 pub snapshot_dir: PathBuf,
39 pub channel_tolerance: u8,
42 pub threshold_percent: f64,
44 pub generate_diff_image: bool,
46 pub update_baselines: bool,
48}
49
50impl Default for VisualOptions {
51 fn default() -> Self {
52 Self {
53 snapshot_dir: PathBuf::from("tests/snapshots"),
54 channel_tolerance: 2,
55 threshold_percent: 0.1,
56 generate_diff_image: true,
57 update_baselines: false,
58 }
59 }
60}
61
62pub fn compare_screenshot(
73 name: &str,
74 screenshot_base64: &str,
75 options: &VisualOptions,
76) -> Result<VisualDiff, TestError> {
77 let screenshot_bytes = base64::engine::general_purpose::STANDARD
78 .decode(screenshot_base64)
79 .map_err(|e| TestError::Other(format!("failed to decode base64 screenshot: {e}")))?;
80
81 std::fs::create_dir_all(&options.snapshot_dir)
82 .map_err(|e| TestError::Other(format!("failed to create snapshot dir: {e}")))?;
83
84 let baseline_path = options.snapshot_dir.join(format!("{name}.png"));
85
86 if options.update_baselines || !baseline_path.exists() {
87 std::fs::write(&baseline_path, &screenshot_bytes)
88 .map_err(|e| TestError::Other(format!("failed to write baseline: {e}")))?;
89
90 return Ok(VisualDiff {
91 match_percentage: 100.0,
92 diff_pixel_count: 0,
93 total_pixels: 0,
94 diff_image_path: None,
95 });
96 }
97
98 let baseline_bytes = std::fs::read(&baseline_path)
99 .map_err(|e| TestError::Other(format!("failed to read baseline: {e}")))?;
100
101 let current = decode_png(&screenshot_bytes)?;
102 let baseline = decode_png(&baseline_bytes)?;
103
104 if current.width != baseline.width || current.height != baseline.height {
105 return Err(TestError::Other(format!(
106 "screenshot size {}x{} doesn't match baseline {}x{}",
107 current.width, current.height, baseline.width, baseline.height
108 )));
109 }
110
111 let diff = compute_diff(¤t, &baseline, options.channel_tolerance);
112 let total_pixels = (current.width * current.height) as usize;
113 let match_percentage = if total_pixels == 0 {
114 100.0
115 } else {
116 (1.0 - diff.len() as f64 / total_pixels as f64) * 100.0
117 };
118
119 let diff_image_path = if !diff.is_empty() && options.generate_diff_image {
120 let diff_path = options.snapshot_dir.join(format!("{name}.diff.png"));
121 write_diff_image(&diff_path, ¤t, &diff)?;
122 Some(diff_path)
123 } else {
124 None
125 };
126
127 let result = VisualDiff {
128 match_percentage,
129 diff_pixel_count: diff.len(),
130 total_pixels,
131 diff_image_path,
132 };
133
134 if !result.is_match(options.threshold_percent) {
135 return Err(TestError::VisualRegression(format!(
136 "visual regression: {:.2}% pixels differ (threshold: {:.2}%)",
137 100.0 - match_percentage,
138 options.threshold_percent
139 )));
140 }
141
142 Ok(result)
143}
144
145struct DecodedImage {
146 width: u32,
147 height: u32,
148 rgba: Vec<u8>,
149}
150
151fn decode_png(data: &[u8]) -> Result<DecodedImage, TestError> {
152 let decoder = png::Decoder::new(std::io::Cursor::new(data));
153 let mut reader = decoder
154 .read_info()
155 .map_err(|e| TestError::Other(format!("PNG decode error: {e}")))?;
156 let mut buf = vec![0; reader.output_buffer_size()];
157 let info = reader
158 .next_frame(&mut buf)
159 .map_err(|e| TestError::Other(format!("PNG frame error: {e}")))?;
160
161 let rgba = match info.color_type {
162 png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),
163 png::ColorType::Rgb => {
164 let rgb = &buf[..info.buffer_size()];
165 let mut rgba = Vec::with_capacity(rgb.len() / 3 * 4);
166 for chunk in rgb.chunks_exact(3) {
167 rgba.extend_from_slice(chunk);
168 rgba.push(255);
169 }
170 rgba
171 }
172 png::ColorType::Grayscale => {
173 let gray = &buf[..info.buffer_size()];
174 let mut rgba = Vec::with_capacity(gray.len() * 4);
175 for &g in gray {
176 rgba.extend_from_slice(&[g, g, g, 255]);
177 }
178 rgba
179 }
180 other => {
181 return Err(TestError::Other(format!(
182 "unsupported PNG color type: {other:?}"
183 )));
184 }
185 };
186
187 Ok(DecodedImage {
188 width: info.width,
189 height: info.height,
190 rgba,
191 })
192}
193
194fn compute_diff(current: &DecodedImage, baseline: &DecodedImage, tolerance: u8) -> Vec<usize> {
195 let mut diff_positions = Vec::new();
196 let pixel_count = (current.width * current.height) as usize;
197
198 for i in 0..pixel_count {
199 let offset = i * 4;
200 if offset + 3 >= current.rgba.len() || offset + 3 >= baseline.rgba.len() {
201 break;
202 }
203 let dr = current.rgba[offset].abs_diff(baseline.rgba[offset]);
204 let dg = current.rgba[offset + 1].abs_diff(baseline.rgba[offset + 1]);
205 let db = current.rgba[offset + 2].abs_diff(baseline.rgba[offset + 2]);
206 let da = current.rgba[offset + 3].abs_diff(baseline.rgba[offset + 3]);
207
208 if dr > tolerance || dg > tolerance || db > tolerance || da > tolerance {
209 diff_positions.push(i);
210 }
211 }
212
213 diff_positions
214}
215
216fn write_diff_image(
217 path: &Path,
218 source: &DecodedImage,
219 diff_positions: &[usize],
220) -> Result<(), TestError> {
221 let mut diff_rgba = source.rgba.clone();
222
223 for &pos in diff_positions {
224 let offset = pos * 4;
225 if offset + 3 < diff_rgba.len() {
226 diff_rgba[offset] = 255; diff_rgba[offset + 1] = 0; diff_rgba[offset + 2] = 0; diff_rgba[offset + 3] = 255; }
231 }
232
233 let file = std::fs::File::create(path)
234 .map_err(|e| TestError::Other(format!("failed to create diff image: {e}")))?;
235 let w = &mut std::io::BufWriter::new(file);
236 let mut encoder = png::Encoder::new(w, source.width, source.height);
237 encoder.set_color(png::ColorType::Rgba);
238 encoder.set_depth(png::BitDepth::Eight);
239 let mut writer = encoder
240 .write_header()
241 .map_err(|e| TestError::Other(format!("PNG encode error: {e}")))?;
242 writer
243 .write_image_data(&diff_rgba)
244 .map_err(|e| TestError::Other(format!("PNG write error: {e}")))?;
245
246 Ok(())
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 fn make_solid_png(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
254 let mut buf = Vec::new();
255 {
256 let mut encoder = png::Encoder::new(&mut buf, width, height);
257 encoder.set_color(png::ColorType::Rgba);
258 encoder.set_depth(png::BitDepth::Eight);
259 let mut writer = encoder.write_header().unwrap();
260 let mut data = Vec::with_capacity((width * height * 4) as usize);
261 for _ in 0..(width * height) {
262 data.extend_from_slice(&[r, g, b, 255]);
263 }
264 writer.write_image_data(&data).unwrap();
265 }
266 buf
267 }
268
269 fn to_base64(data: &[u8]) -> String {
270 base64::engine::general_purpose::STANDARD.encode(data)
271 }
272
273 #[test]
274 fn identical_images_match() {
275 let dir = tempfile::tempdir().unwrap();
276 let png = make_solid_png(10, 10, 128, 128, 128);
277 let b64 = to_base64(&png);
278
279 let opts = VisualOptions {
280 snapshot_dir: dir.path().to_path_buf(),
281 ..VisualOptions::default()
282 };
283
284 let result = compare_screenshot("test_identical", &b64, &opts).unwrap();
286 assert_eq!(result.match_percentage, 100.0);
287
288 let result = compare_screenshot("test_identical", &b64, &opts).unwrap();
290 assert_eq!(result.match_percentage, 100.0);
291 assert_eq!(result.diff_pixel_count, 0);
292 }
293
294 #[test]
295 fn different_images_detected() {
296 let dir = tempfile::tempdir().unwrap();
297 let baseline = make_solid_png(10, 10, 128, 128, 128);
298 let changed = make_solid_png(10, 10, 255, 0, 0);
299
300 let opts = VisualOptions {
301 snapshot_dir: dir.path().to_path_buf(),
302 generate_diff_image: true,
303 threshold_percent: 0.1,
304 ..VisualOptions::default()
305 };
306
307 compare_screenshot("test_diff", &to_base64(&baseline), &opts).unwrap();
309
310 let err = compare_screenshot("test_diff", &to_base64(&changed), &opts).unwrap_err();
312 match err {
313 TestError::VisualRegression(msg) => {
314 assert!(msg.contains("visual regression"), "got: {msg}");
315 }
316 other => panic!("expected VisualRegression, got: {other:?}"),
317 }
318
319 assert!(dir.path().join("test_diff.diff.png").exists());
321 }
322
323 #[test]
324 fn tolerance_allows_minor_diffs() {
325 let dir = tempfile::tempdir().unwrap();
326 let baseline = make_solid_png(10, 10, 128, 128, 128);
327 let slightly_off = make_solid_png(10, 10, 129, 128, 128);
328
329 let opts = VisualOptions {
330 snapshot_dir: dir.path().to_path_buf(),
331 channel_tolerance: 2,
332 threshold_percent: 1.0,
333 ..VisualOptions::default()
334 };
335
336 compare_screenshot("test_tol", &to_base64(&baseline), &opts).unwrap();
337 let result = compare_screenshot("test_tol", &to_base64(&slightly_off), &opts).unwrap();
338 assert_eq!(result.match_percentage, 100.0);
339 }
340
341 #[test]
342 fn update_baselines_overwrites() {
343 let dir = tempfile::tempdir().unwrap();
344 let first = make_solid_png(5, 5, 100, 100, 100);
345 let second = make_solid_png(5, 5, 200, 200, 200);
346
347 let mut opts = VisualOptions {
348 snapshot_dir: dir.path().to_path_buf(),
349 ..VisualOptions::default()
350 };
351
352 compare_screenshot("test_update", &to_base64(&first), &opts).unwrap();
353
354 opts.update_baselines = true;
355 let result = compare_screenshot("test_update", &to_base64(&second), &opts).unwrap();
356 assert_eq!(result.match_percentage, 100.0);
357
358 opts.update_baselines = false;
360 let result = compare_screenshot("test_update", &to_base64(&second), &opts).unwrap();
361 assert_eq!(result.match_percentage, 100.0);
362 }
363
364 #[test]
365 fn size_mismatch_returns_error() {
366 let dir = tempfile::tempdir().unwrap();
367 let small = make_solid_png(5, 5, 128, 128, 128);
368 let big = make_solid_png(10, 10, 128, 128, 128);
369
370 let opts = VisualOptions {
371 snapshot_dir: dir.path().to_path_buf(),
372 ..VisualOptions::default()
373 };
374
375 compare_screenshot("test_size", &to_base64(&small), &opts).unwrap();
376 let err = compare_screenshot("test_size", &to_base64(&big), &opts).unwrap_err();
377 match err {
378 TestError::Other(msg) => assert!(msg.contains("size"), "got: {msg}"),
379 other => panic!("expected Other, got: {other:?}"),
380 }
381 }
382
383 #[test]
384 fn first_run_creates_baseline() {
385 let dir = tempfile::tempdir().unwrap();
386 let png = make_solid_png(3, 3, 64, 64, 64);
387
388 let opts = VisualOptions {
389 snapshot_dir: dir.path().to_path_buf(),
390 ..VisualOptions::default()
391 };
392
393 assert!(!dir.path().join("new_test.png").exists());
394 compare_screenshot("new_test", &to_base64(&png), &opts).unwrap();
395 assert!(dir.path().join("new_test.png").exists());
396 }
397
398 fn make_rgb_png(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
399 let mut buf = Vec::new();
400 {
401 let mut encoder = png::Encoder::new(&mut buf, width, height);
402 encoder.set_color(png::ColorType::Rgb);
403 encoder.set_depth(png::BitDepth::Eight);
404 let mut writer = encoder.write_header().unwrap();
405 let mut data = Vec::with_capacity((width * height * 3) as usize);
406 for _ in 0..(width * height) {
407 data.extend_from_slice(&[r, g, b]);
408 }
409 writer.write_image_data(&data).unwrap();
410 }
411 buf
412 }
413
414 fn make_grayscale_png(width: u32, height: u32, value: u8) -> Vec<u8> {
415 let mut buf = Vec::new();
416 {
417 let mut encoder = png::Encoder::new(&mut buf, width, height);
418 encoder.set_color(png::ColorType::Grayscale);
419 encoder.set_depth(png::BitDepth::Eight);
420 let mut writer = encoder.write_header().unwrap();
421 let data = vec![value; (width * height) as usize];
422 writer.write_image_data(&data).unwrap();
423 }
424 buf
425 }
426
427 #[test]
428 fn rgb_png_converts_to_rgba() {
429 let dir = tempfile::tempdir().unwrap();
430 let baseline = make_solid_png(8, 8, 200, 100, 50);
432 let screenshot = make_rgb_png(8, 8, 200, 100, 50);
434
435 let opts = VisualOptions {
436 snapshot_dir: dir.path().to_path_buf(),
437 channel_tolerance: 0,
438 threshold_percent: 0.1,
439 ..VisualOptions::default()
440 };
441
442 compare_screenshot("rgb_test", &to_base64(&baseline), &opts).unwrap();
443 let result = compare_screenshot("rgb_test", &to_base64(&screenshot), &opts).unwrap();
444 assert_eq!(result.match_percentage, 100.0);
445 assert_eq!(result.diff_pixel_count, 0);
446 }
447
448 #[test]
449 fn grayscale_png_converts_to_rgba() {
450 let dir = tempfile::tempdir().unwrap();
451 let gray_value: u8 = 128;
452 let baseline = make_solid_png(6, 6, gray_value, gray_value, gray_value);
454 let screenshot = make_grayscale_png(6, 6, gray_value);
456
457 let opts = VisualOptions {
458 snapshot_dir: dir.path().to_path_buf(),
459 channel_tolerance: 0,
460 threshold_percent: 0.1,
461 ..VisualOptions::default()
462 };
463
464 compare_screenshot("gray_test", &to_base64(&baseline), &opts).unwrap();
465 let result = compare_screenshot("gray_test", &to_base64(&screenshot), &opts).unwrap();
466 assert_eq!(result.match_percentage, 100.0);
467 assert_eq!(result.diff_pixel_count, 0);
468 }
469
470 #[test]
471 fn is_match_threshold_logic() {
472 let diff = VisualDiff {
473 match_percentage: 99.5,
474 diff_pixel_count: 5,
475 total_pixels: 1000,
476 diff_image_path: None,
477 };
478 assert!(diff.is_match(1.0));
480 assert!(diff.is_match(0.5));
482 assert!(!diff.is_match(0.1));
484 }
485}