1use std::path::{Path, PathBuf};
8
9use base64::Engine;
10
11use crate::error::TestError;
12
13#[derive(Debug, Clone)]
15pub struct MaskRegion {
16 pub x: u32,
18 pub y: u32,
20 pub width: u32,
22 pub height: u32,
24}
25
26impl MaskRegion {
27 #[must_use]
29 pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
30 Self {
31 x,
32 y,
33 width,
34 height,
35 }
36 }
37
38 fn contains(&self, px: u32, py: u32) -> bool {
39 px >= self.x && px < self.x + self.width && py >= self.y && py < self.y + self.height
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ThresholdPreset {
46 Strict,
48 Standard,
50 AntiAlias,
52 Relaxed,
54}
55
56impl ThresholdPreset {
57 fn channel_tolerance(self) -> u8 {
58 match self {
59 Self::Strict => 0,
60 Self::Standard => 2,
61 Self::AntiAlias => 5,
62 Self::Relaxed => 10,
63 }
64 }
65
66 fn threshold_percent(self) -> f64 {
67 match self {
68 Self::Strict => 0.0,
69 Self::Standard => 0.1,
70 Self::AntiAlias => 0.5,
71 Self::Relaxed => 2.0,
72 }
73 }
74}
75
76#[derive(Debug)]
78pub struct VisualDiff {
79 pub match_percentage: f64,
81 pub diff_pixel_count: usize,
83 pub total_pixels: usize,
85 pub masked_pixels: usize,
87 pub diff_image_path: Option<PathBuf>,
89}
90
91impl VisualDiff {
92 #[must_use]
94 pub fn is_match(&self, threshold_percent: f64) -> bool {
95 self.match_percentage >= (100.0 - threshold_percent)
96 }
97}
98
99#[derive(Debug, Clone)]
101pub struct VisualOptions {
102 pub snapshot_dir: PathBuf,
104 pub channel_tolerance: u8,
107 pub threshold_percent: f64,
109 pub generate_diff_image: bool,
111 pub update_baselines: bool,
113 pub mask_regions: Vec<MaskRegion>,
115 pub platform_baselines: bool,
118}
119
120impl Default for VisualOptions {
121 fn default() -> Self {
122 Self {
123 snapshot_dir: PathBuf::from("tests/snapshots"),
124 channel_tolerance: 2,
125 threshold_percent: 0.1,
126 generate_diff_image: true,
127 update_baselines: false,
128 mask_regions: Vec::new(),
129 platform_baselines: true,
130 }
131 }
132}
133
134impl VisualOptions {
135 #[must_use]
138 pub fn with_preset(mut self, preset: ThresholdPreset) -> Self {
139 self.channel_tolerance = preset.channel_tolerance();
140 self.threshold_percent = preset.threshold_percent();
141 self
142 }
143
144 #[must_use]
146 pub fn with_mask(mut self, region: MaskRegion) -> Self {
147 self.mask_regions.push(region);
148 self
149 }
150
151 fn effective_snapshot_dir(&self) -> PathBuf {
152 if self.platform_baselines {
153 self.snapshot_dir.join(std::env::consts::OS)
154 } else {
155 self.snapshot_dir.clone()
156 }
157 }
158}
159
160pub fn compare_screenshot(
171 name: &str,
172 screenshot_base64: &str,
173 options: &VisualOptions,
174) -> Result<VisualDiff, TestError> {
175 let screenshot_bytes = base64::engine::general_purpose::STANDARD
176 .decode(screenshot_base64)
177 .map_err(|e| TestError::Other(format!("failed to decode base64 screenshot: {e}")))?;
178
179 let snap_dir = options.effective_snapshot_dir();
180 std::fs::create_dir_all(&snap_dir)
181 .map_err(|e| TestError::Other(format!("failed to create snapshot dir: {e}")))?;
182
183 let baseline_path = snap_dir.join(format!("{name}.png"));
184
185 if options.update_baselines || !baseline_path.exists() {
186 std::fs::write(&baseline_path, &screenshot_bytes)
187 .map_err(|e| TestError::Other(format!("failed to write baseline: {e}")))?;
188
189 return Ok(VisualDiff {
190 match_percentage: 100.0,
191 diff_pixel_count: 0,
192 total_pixels: 0,
193 masked_pixels: 0,
194 diff_image_path: None,
195 });
196 }
197
198 let baseline_bytes = std::fs::read(&baseline_path)
199 .map_err(|e| TestError::Other(format!("failed to read baseline: {e}")))?;
200
201 let current = decode_png(&screenshot_bytes)?;
202 let baseline = decode_png(&baseline_bytes)?;
203
204 if current.width != baseline.width || current.height != baseline.height {
205 return Err(TestError::Other(format!(
206 "screenshot size {}x{} doesn't match baseline {}x{}",
207 current.width, current.height, baseline.width, baseline.height
208 )));
209 }
210
211 let (diff, masked) = compute_diff(
212 ¤t,
213 &baseline,
214 options.channel_tolerance,
215 &options.mask_regions,
216 );
217 let total_pixels = (current.width * current.height) as usize - masked;
218 let match_percentage = if total_pixels == 0 {
219 100.0
220 } else {
221 (1.0 - diff.len() as f64 / total_pixels as f64) * 100.0
222 };
223
224 let diff_image_path = if !diff.is_empty() && options.generate_diff_image {
225 let diff_path = snap_dir.join(format!("{name}.diff.png"));
226 write_diff_image(&diff_path, ¤t, &diff)?;
227 Some(diff_path)
228 } else {
229 None
230 };
231
232 let result = VisualDiff {
233 match_percentage,
234 diff_pixel_count: diff.len(),
235 total_pixels,
236 masked_pixels: masked,
237 diff_image_path,
238 };
239
240 if !result.is_match(options.threshold_percent) {
241 return Err(TestError::VisualRegression(format!(
242 "visual regression: {:.2}% pixels differ (threshold: {:.2}%)",
243 100.0 - match_percentage,
244 options.threshold_percent
245 )));
246 }
247
248 Ok(result)
249}
250
251struct DecodedImage {
252 width: u32,
253 height: u32,
254 rgba: Vec<u8>,
255}
256
257fn decode_png(data: &[u8]) -> Result<DecodedImage, TestError> {
258 let decoder = png::Decoder::new(std::io::Cursor::new(data));
259 let mut reader = decoder
260 .read_info()
261 .map_err(|e| TestError::Other(format!("PNG decode error: {e}")))?;
262 let mut buf = vec![0; reader.output_buffer_size()];
263 let info = reader
264 .next_frame(&mut buf)
265 .map_err(|e| TestError::Other(format!("PNG frame error: {e}")))?;
266
267 let rgba = match info.color_type {
268 png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),
269 png::ColorType::Rgb => {
270 let rgb = &buf[..info.buffer_size()];
271 let mut rgba = Vec::with_capacity(rgb.len() / 3 * 4);
272 for chunk in rgb.chunks_exact(3) {
273 rgba.extend_from_slice(chunk);
274 rgba.push(255);
275 }
276 rgba
277 }
278 png::ColorType::Grayscale => {
279 let gray = &buf[..info.buffer_size()];
280 let mut rgba = Vec::with_capacity(gray.len() * 4);
281 for &g in gray {
282 rgba.extend_from_slice(&[g, g, g, 255]);
283 }
284 rgba
285 }
286 other => {
287 return Err(TestError::Other(format!(
288 "unsupported PNG color type: {other:?}"
289 )));
290 }
291 };
292
293 Ok(DecodedImage {
294 width: info.width,
295 height: info.height,
296 rgba,
297 })
298}
299
300fn compute_diff(
301 current: &DecodedImage,
302 baseline: &DecodedImage,
303 tolerance: u8,
304 masks: &[MaskRegion],
305) -> (Vec<usize>, usize) {
306 let mut diff_positions = Vec::new();
307 let mut masked_count = 0usize;
308 let pixel_count = (current.width * current.height) as usize;
309
310 for i in 0..pixel_count {
311 let offset = i * 4;
312 if offset + 3 >= current.rgba.len() || offset + 3 >= baseline.rgba.len() {
313 break;
314 }
315
316 if !masks.is_empty() {
317 let px = (i as u32) % current.width;
318 let py = (i as u32) / current.width;
319 if masks.iter().any(|m| m.contains(px, py)) {
320 masked_count += 1;
321 continue;
322 }
323 }
324
325 let dr = current.rgba[offset].abs_diff(baseline.rgba[offset]);
326 let dg = current.rgba[offset + 1].abs_diff(baseline.rgba[offset + 1]);
327 let db = current.rgba[offset + 2].abs_diff(baseline.rgba[offset + 2]);
328 let da = current.rgba[offset + 3].abs_diff(baseline.rgba[offset + 3]);
329
330 if dr > tolerance || dg > tolerance || db > tolerance || da > tolerance {
331 diff_positions.push(i);
332 }
333 }
334
335 (diff_positions, masked_count)
336}
337
338fn write_diff_image(
339 path: &Path,
340 source: &DecodedImage,
341 diff_positions: &[usize],
342) -> Result<(), TestError> {
343 let mut diff_rgba = source.rgba.clone();
344
345 for &pos in diff_positions {
346 let offset = pos * 4;
347 if offset + 3 < diff_rgba.len() {
348 diff_rgba[offset] = 255; diff_rgba[offset + 1] = 0; diff_rgba[offset + 2] = 0; diff_rgba[offset + 3] = 255; }
353 }
354
355 let file = std::fs::File::create(path)
356 .map_err(|e| TestError::Other(format!("failed to create diff image: {e}")))?;
357 let w = &mut std::io::BufWriter::new(file);
358 let mut encoder = png::Encoder::new(w, source.width, source.height);
359 encoder.set_color(png::ColorType::Rgba);
360 encoder.set_depth(png::BitDepth::Eight);
361 let mut writer = encoder
362 .write_header()
363 .map_err(|e| TestError::Other(format!("PNG encode error: {e}")))?;
364 writer
365 .write_image_data(&diff_rgba)
366 .map_err(|e| TestError::Other(format!("PNG write error: {e}")))?;
367
368 Ok(())
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 fn make_solid_png(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
376 let mut buf = Vec::new();
377 {
378 let mut encoder = png::Encoder::new(&mut buf, width, height);
379 encoder.set_color(png::ColorType::Rgba);
380 encoder.set_depth(png::BitDepth::Eight);
381 let mut writer = encoder.write_header().unwrap();
382 let mut data = Vec::with_capacity((width * height * 4) as usize);
383 for _ in 0..(width * height) {
384 data.extend_from_slice(&[r, g, b, 255]);
385 }
386 writer.write_image_data(&data).unwrap();
387 }
388 buf
389 }
390
391 fn to_base64(data: &[u8]) -> String {
392 base64::engine::general_purpose::STANDARD.encode(data)
393 }
394
395 #[test]
396 fn identical_images_match() {
397 let dir = tempfile::tempdir().unwrap();
398 let png = make_solid_png(10, 10, 128, 128, 128);
399 let b64 = to_base64(&png);
400
401 let opts = VisualOptions {
402 snapshot_dir: dir.path().to_path_buf(),
403 platform_baselines: false,
404 ..VisualOptions::default()
405 };
406
407 let result = compare_screenshot("test_identical", &b64, &opts).unwrap();
409 assert_eq!(result.match_percentage, 100.0);
410
411 let result = compare_screenshot("test_identical", &b64, &opts).unwrap();
413 assert_eq!(result.match_percentage, 100.0);
414 assert_eq!(result.diff_pixel_count, 0);
415 }
416
417 #[test]
418 fn different_images_detected() {
419 let dir = tempfile::tempdir().unwrap();
420 let baseline = make_solid_png(10, 10, 128, 128, 128);
421 let changed = make_solid_png(10, 10, 255, 0, 0);
422
423 let opts = VisualOptions {
424 snapshot_dir: dir.path().to_path_buf(),
425 generate_diff_image: true,
426 threshold_percent: 0.1,
427 platform_baselines: false,
428 ..VisualOptions::default()
429 };
430
431 compare_screenshot("test_diff", &to_base64(&baseline), &opts).unwrap();
433
434 let err = compare_screenshot("test_diff", &to_base64(&changed), &opts).unwrap_err();
436 match err {
437 TestError::VisualRegression(msg) => {
438 assert!(msg.contains("visual regression"), "got: {msg}");
439 }
440 other => panic!("expected VisualRegression, got: {other:?}"),
441 }
442
443 assert!(dir.path().join("test_diff.diff.png").exists());
445 }
446
447 #[test]
448 fn tolerance_allows_minor_diffs() {
449 let dir = tempfile::tempdir().unwrap();
450 let baseline = make_solid_png(10, 10, 128, 128, 128);
451 let slightly_off = make_solid_png(10, 10, 129, 128, 128);
452
453 let opts = VisualOptions {
454 snapshot_dir: dir.path().to_path_buf(),
455 channel_tolerance: 2,
456 threshold_percent: 1.0,
457 platform_baselines: false,
458 ..VisualOptions::default()
459 };
460
461 compare_screenshot("test_tol", &to_base64(&baseline), &opts).unwrap();
462 let result = compare_screenshot("test_tol", &to_base64(&slightly_off), &opts).unwrap();
463 assert_eq!(result.match_percentage, 100.0);
464 }
465
466 #[test]
467 fn update_baselines_overwrites() {
468 let dir = tempfile::tempdir().unwrap();
469 let first = make_solid_png(5, 5, 100, 100, 100);
470 let second = make_solid_png(5, 5, 200, 200, 200);
471
472 let mut opts = VisualOptions {
473 snapshot_dir: dir.path().to_path_buf(),
474 platform_baselines: false,
475 ..VisualOptions::default()
476 };
477
478 compare_screenshot("test_update", &to_base64(&first), &opts).unwrap();
479
480 opts.update_baselines = true;
481 let result = compare_screenshot("test_update", &to_base64(&second), &opts).unwrap();
482 assert_eq!(result.match_percentage, 100.0);
483
484 opts.update_baselines = false;
486 let result = compare_screenshot("test_update", &to_base64(&second), &opts).unwrap();
487 assert_eq!(result.match_percentage, 100.0);
488 }
489
490 #[test]
491 fn size_mismatch_returns_error() {
492 let dir = tempfile::tempdir().unwrap();
493 let small = make_solid_png(5, 5, 128, 128, 128);
494 let big = make_solid_png(10, 10, 128, 128, 128);
495
496 let opts = VisualOptions {
497 snapshot_dir: dir.path().to_path_buf(),
498 platform_baselines: false,
499 ..VisualOptions::default()
500 };
501
502 compare_screenshot("test_size", &to_base64(&small), &opts).unwrap();
503 let err = compare_screenshot("test_size", &to_base64(&big), &opts).unwrap_err();
504 match err {
505 TestError::Other(msg) => assert!(msg.contains("size"), "got: {msg}"),
506 other => panic!("expected Other, got: {other:?}"),
507 }
508 }
509
510 #[test]
511 fn first_run_creates_baseline() {
512 let dir = tempfile::tempdir().unwrap();
513 let png = make_solid_png(3, 3, 64, 64, 64);
514
515 let opts = VisualOptions {
516 snapshot_dir: dir.path().to_path_buf(),
517 platform_baselines: false,
518 ..VisualOptions::default()
519 };
520
521 assert!(!dir.path().join("new_test.png").exists());
522 compare_screenshot("new_test", &to_base64(&png), &opts).unwrap();
523 assert!(dir.path().join("new_test.png").exists());
524 }
525
526 fn make_rgb_png(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
527 let mut buf = Vec::new();
528 {
529 let mut encoder = png::Encoder::new(&mut buf, width, height);
530 encoder.set_color(png::ColorType::Rgb);
531 encoder.set_depth(png::BitDepth::Eight);
532 let mut writer = encoder.write_header().unwrap();
533 let mut data = Vec::with_capacity((width * height * 3) as usize);
534 for _ in 0..(width * height) {
535 data.extend_from_slice(&[r, g, b]);
536 }
537 writer.write_image_data(&data).unwrap();
538 }
539 buf
540 }
541
542 fn make_grayscale_png(width: u32, height: u32, value: u8) -> Vec<u8> {
543 let mut buf = Vec::new();
544 {
545 let mut encoder = png::Encoder::new(&mut buf, width, height);
546 encoder.set_color(png::ColorType::Grayscale);
547 encoder.set_depth(png::BitDepth::Eight);
548 let mut writer = encoder.write_header().unwrap();
549 let data = vec![value; (width * height) as usize];
550 writer.write_image_data(&data).unwrap();
551 }
552 buf
553 }
554
555 #[test]
556 fn rgb_png_converts_to_rgba() {
557 let dir = tempfile::tempdir().unwrap();
558 let baseline = make_solid_png(8, 8, 200, 100, 50);
560 let screenshot = make_rgb_png(8, 8, 200, 100, 50);
562
563 let opts = VisualOptions {
564 snapshot_dir: dir.path().to_path_buf(),
565 channel_tolerance: 0,
566 threshold_percent: 0.1,
567 platform_baselines: false,
568 ..VisualOptions::default()
569 };
570
571 compare_screenshot("rgb_test", &to_base64(&baseline), &opts).unwrap();
572 let result = compare_screenshot("rgb_test", &to_base64(&screenshot), &opts).unwrap();
573 assert_eq!(result.match_percentage, 100.0);
574 assert_eq!(result.diff_pixel_count, 0);
575 }
576
577 #[test]
578 fn grayscale_png_converts_to_rgba() {
579 let dir = tempfile::tempdir().unwrap();
580 let gray_value: u8 = 128;
581 let baseline = make_solid_png(6, 6, gray_value, gray_value, gray_value);
583 let screenshot = make_grayscale_png(6, 6, gray_value);
585
586 let opts = VisualOptions {
587 snapshot_dir: dir.path().to_path_buf(),
588 channel_tolerance: 0,
589 threshold_percent: 0.1,
590 platform_baselines: false,
591 ..VisualOptions::default()
592 };
593
594 compare_screenshot("gray_test", &to_base64(&baseline), &opts).unwrap();
595 let result = compare_screenshot("gray_test", &to_base64(&screenshot), &opts).unwrap();
596 assert_eq!(result.match_percentage, 100.0);
597 assert_eq!(result.diff_pixel_count, 0);
598 }
599
600 #[test]
601 fn is_match_threshold_logic() {
602 let diff = VisualDiff {
603 match_percentage: 99.5,
604 diff_pixel_count: 5,
605 total_pixels: 1000,
606 masked_pixels: 0,
607 diff_image_path: None,
608 };
609 assert!(diff.is_match(1.0));
611 assert!(diff.is_match(0.5));
613 assert!(!diff.is_match(0.1));
615 }
616
617 #[test]
618 fn mask_region_excludes_pixels() {
619 let dir = tempfile::tempdir().unwrap();
620 let baseline = make_solid_png(10, 10, 128, 128, 128);
621 let changed = make_solid_png(10, 10, 255, 0, 0);
623
624 let opts = VisualOptions {
625 snapshot_dir: dir.path().to_path_buf(),
626 threshold_percent: 0.1,
627 mask_regions: vec![MaskRegion::new(0, 0, 10, 10)],
628 platform_baselines: false,
629 ..VisualOptions::default()
630 };
631
632 compare_screenshot("mask_all", &to_base64(&baseline), &opts).unwrap();
633 let result = compare_screenshot("mask_all", &to_base64(&changed), &opts).unwrap();
634 assert_eq!(result.match_percentage, 100.0);
635 assert_eq!(result.masked_pixels, 100);
636 assert_eq!(result.diff_pixel_count, 0);
637 }
638
639 #[test]
640 fn mask_region_partial_exclusion() {
641 let dir = tempfile::tempdir().unwrap();
642 let baseline = make_solid_png(4, 4, 100, 100, 100);
644 let changed = make_solid_png(4, 4, 200, 200, 200);
645
646 let opts = VisualOptions {
647 snapshot_dir: dir.path().to_path_buf(),
648 channel_tolerance: 0,
649 threshold_percent: 100.0,
650 mask_regions: vec![MaskRegion::new(0, 0, 2, 2)],
651 platform_baselines: false,
652 ..VisualOptions::default()
653 };
654
655 compare_screenshot("mask_partial", &to_base64(&baseline), &opts).unwrap();
656 let result = compare_screenshot("mask_partial", &to_base64(&changed), &opts).unwrap();
657 assert_eq!(result.masked_pixels, 4);
658 assert_eq!(result.diff_pixel_count, 12);
660 assert_eq!(result.total_pixels, 12);
661 }
662
663 #[test]
664 fn threshold_preset_strict() {
665 let opts = VisualOptions::default().with_preset(ThresholdPreset::Strict);
666 assert_eq!(opts.channel_tolerance, 0);
667 assert!((opts.threshold_percent - 0.0).abs() < f64::EPSILON);
668 }
669
670 #[test]
671 fn threshold_preset_relaxed() {
672 let opts = VisualOptions::default().with_preset(ThresholdPreset::Relaxed);
673 assert_eq!(opts.channel_tolerance, 10);
674 assert!((opts.threshold_percent - 2.0).abs() < f64::EPSILON);
675 }
676
677 #[test]
678 fn threshold_preset_anti_alias() {
679 let opts = VisualOptions::default().with_preset(ThresholdPreset::AntiAlias);
680 assert_eq!(opts.channel_tolerance, 5);
681 assert!((opts.threshold_percent - 0.5).abs() < f64::EPSILON);
682 }
683
684 #[test]
685 fn platform_baselines_creates_os_subdir() {
686 let dir = tempfile::tempdir().unwrap();
687 let png = make_solid_png(4, 4, 64, 64, 64);
688
689 let opts = VisualOptions {
690 snapshot_dir: dir.path().to_path_buf(),
691 platform_baselines: true,
692 ..VisualOptions::default()
693 };
694
695 compare_screenshot("plattest", &to_base64(&png), &opts).unwrap();
696 let expected = dir.path().join(std::env::consts::OS).join("plattest.png");
697 assert!(expected.exists(), "baseline not at {}", expected.display());
698 }
699
700 #[test]
701 fn platform_baselines_disabled_uses_root() {
702 let dir = tempfile::tempdir().unwrap();
703 let png = make_solid_png(4, 4, 64, 64, 64);
704
705 let opts = VisualOptions {
706 snapshot_dir: dir.path().to_path_buf(),
707 platform_baselines: false,
708 ..VisualOptions::default()
709 };
710
711 compare_screenshot("noplattest", &to_base64(&png), &opts).unwrap();
712 let expected = dir.path().join("noplattest.png");
713 assert!(expected.exists(), "baseline not at {}", expected.display());
714 assert!(!dir.path().join(std::env::consts::OS).exists());
716 }
717
718 #[test]
719 fn with_mask_builder_chains() {
720 let opts = VisualOptions::default()
721 .with_mask(MaskRegion::new(0, 0, 50, 50))
722 .with_mask(MaskRegion::new(100, 100, 25, 25));
723 assert_eq!(opts.mask_regions.len(), 2);
724 }
725}