1use crate::result::{ProbarError, ProbarResult};
6use image::{DynamicImage, GenericImageView, ImageEncoder, Rgba};
7use std::path::Path;
8
9#[derive(Debug, Clone)]
11pub struct VisualRegressionConfig {
12 pub threshold: f64,
14 pub color_threshold: u8,
16 pub baseline_dir: String,
18 pub diff_dir: String,
20 pub update_baselines: bool,
22}
23
24impl Default for VisualRegressionConfig {
25 fn default() -> Self {
26 Self {
27 threshold: 0.01, color_threshold: 10, baseline_dir: String::from("__baselines__"),
30 diff_dir: String::from("__diffs__"),
31 update_baselines: false,
32 }
33 }
34}
35
36impl VisualRegressionConfig {
37 #[must_use]
39 pub const fn with_threshold(mut self, threshold: f64) -> Self {
40 self.threshold = threshold;
41 self
42 }
43
44 #[must_use]
46 pub const fn with_color_threshold(mut self, threshold: u8) -> Self {
47 self.color_threshold = threshold;
48 self
49 }
50
51 #[must_use]
53 pub fn with_baseline_dir(mut self, dir: impl Into<String>) -> Self {
54 self.baseline_dir = dir.into();
55 self
56 }
57
58 #[must_use]
60 pub const fn with_update_baselines(mut self, update: bool) -> Self {
61 self.update_baselines = update;
62 self
63 }
64}
65
66#[derive(Debug, Clone)]
68pub struct ImageDiffResult {
69 pub matches: bool,
71 pub diff_pixel_count: usize,
73 pub total_pixels: usize,
75 pub diff_percentage: f64,
77 pub max_color_diff: u32,
79 pub avg_color_diff: f64,
81 pub diff_image: Option<Vec<u8>>,
83}
84
85impl ImageDiffResult {
86 #[must_use]
88 pub const fn is_identical(&self) -> bool {
89 self.diff_pixel_count == 0
90 }
91
92 #[must_use]
94 pub fn within_threshold(&self, threshold: f64) -> bool {
95 self.diff_percentage <= threshold * 100.0
96 }
97}
98
99#[derive(Debug, Clone)]
101pub struct VisualRegressionTester {
102 config: VisualRegressionConfig,
103}
104
105impl Default for VisualRegressionTester {
106 fn default() -> Self {
107 Self::new(VisualRegressionConfig::default())
108 }
109}
110
111impl VisualRegressionTester {
112 #[must_use]
114 pub const fn new(config: VisualRegressionConfig) -> Self {
115 Self { config }
116 }
117
118 pub fn compare_images(&self, actual: &[u8], expected: &[u8]) -> ProbarResult<ImageDiffResult> {
124 let actual_img =
125 image::load_from_memory(actual).map_err(|e| ProbarError::ImageComparisonError {
126 message: format!("Failed to decode actual image: {e}"),
127 })?;
128
129 let expected_img =
130 image::load_from_memory(expected).map_err(|e| ProbarError::ImageComparisonError {
131 message: format!("Failed to decode expected image: {e}"),
132 })?;
133
134 self.compare_dynamic_images(&actual_img, &expected_img)
135 }
136
137 pub fn compare_dynamic_images(
143 &self,
144 actual: &DynamicImage,
145 expected: &DynamicImage,
146 ) -> ProbarResult<ImageDiffResult> {
147 let (width, height) = actual.dimensions();
148 let (exp_width, exp_height) = expected.dimensions();
149
150 if width != exp_width || height != exp_height {
152 return Err(ProbarError::ImageComparisonError {
153 message: format!(
154 "Image dimensions differ: actual {width}x{height}, expected {exp_width}x{exp_height}"
155 ),
156 });
157 }
158
159 let total_pixels = (width * height) as usize;
160 let mut diff_pixel_count = 0usize;
161 let mut max_color_diff: u32 = 0;
162 let mut total_color_diff: u64 = 0;
163
164 let mut diff_img = image::RgbaImage::new(width, height);
166
167 let actual_rgba = actual.to_rgba8();
168 let expected_rgba = expected.to_rgba8();
169
170 for y in 0..height {
171 for x in 0..width {
172 let actual_pixel = actual_rgba.get_pixel(x, y);
173 let expected_pixel = expected_rgba.get_pixel(x, y);
174
175 let color_diff = pixel_diff(*actual_pixel, *expected_pixel);
176
177 if color_diff > u32::from(self.config.color_threshold) {
178 diff_pixel_count += 1;
179 total_color_diff += u64::from(color_diff);
180 max_color_diff = max_color_diff.max(color_diff);
181
182 diff_img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
184 } else {
185 let Rgba([r, g, b, _]) = *actual_pixel;
187 diff_img.put_pixel(x, y, Rgba([r / 2, g / 2, b / 2, 128]));
188 }
189 }
190 }
191
192 #[allow(clippy::cast_precision_loss)]
193 let diff_percentage = if total_pixels > 0 {
194 (diff_pixel_count as f64 / total_pixels as f64) * 100.0
195 } else {
196 0.0
197 };
198
199 #[allow(clippy::cast_precision_loss)]
200 let avg_color_diff = if diff_pixel_count > 0 {
201 total_color_diff as f64 / diff_pixel_count as f64
202 } else {
203 0.0
204 };
205
206 let matches = diff_percentage <= self.config.threshold * 100.0;
207
208 let diff_image = if matches {
210 None
211 } else {
212 let mut buffer = Vec::new();
213 let encoder = image::codecs::png::PngEncoder::new(&mut buffer);
214 encoder
215 .write_image(
216 diff_img.as_raw(),
217 width,
218 height,
219 image::ExtendedColorType::Rgba8,
220 )
221 .map_err(|e| ProbarError::ImageComparisonError {
222 message: format!("Failed to encode diff image: {e}"),
223 })?;
224 Some(buffer)
225 };
226
227 Ok(ImageDiffResult {
228 matches,
229 diff_pixel_count,
230 total_pixels,
231 diff_percentage,
232 max_color_diff,
233 avg_color_diff,
234 diff_image,
235 })
236 }
237
238 pub fn compare_against_baseline(
244 &self,
245 name: &str,
246 screenshot: &[u8],
247 ) -> ProbarResult<ImageDiffResult> {
248 let baseline_path = Path::new(&self.config.baseline_dir).join(format!("{name}.png"));
249
250 if !baseline_path.exists() {
251 if self.config.update_baselines {
252 std::fs::create_dir_all(&self.config.baseline_dir)?;
254 std::fs::write(&baseline_path, screenshot)?;
255 return Ok(ImageDiffResult {
256 matches: true,
257 diff_pixel_count: 0,
258 total_pixels: 0,
259 diff_percentage: 0.0,
260 max_color_diff: 0,
261 avg_color_diff: 0.0,
262 diff_image: None,
263 });
264 }
265 return Err(ProbarError::ImageComparisonError {
266 message: format!("Baseline not found: {}", baseline_path.display()),
267 });
268 }
269
270 let baseline = std::fs::read(&baseline_path)?;
271 let result = self.compare_images(screenshot, &baseline)?;
272
273 if !result.matches {
275 if let Some(ref diff_data) = result.diff_image {
276 std::fs::create_dir_all(&self.config.diff_dir)?;
277 let diff_path = Path::new(&self.config.diff_dir).join(format!("{name}_diff.png"));
278 std::fs::write(&diff_path, diff_data)?;
279 }
280 }
281
282 if self.config.update_baselines && !result.matches {
284 std::fs::write(&baseline_path, screenshot)?;
285 }
286
287 Ok(result)
288 }
289
290 #[must_use]
292 pub const fn config(&self) -> &VisualRegressionConfig {
293 &self.config
294 }
295}
296
297fn pixel_diff(a: Rgba<u8>, b: Rgba<u8>) -> u32 {
299 let Rgba([r1, g1, b1, _]) = a;
300 let Rgba([r2, g2, b2, _]) = b;
301
302 let dr = i32::from(r1) - i32::from(r2);
303 let dg = i32::from(g1) - i32::from(g2);
304 let db = i32::from(b1) - i32::from(b2);
305
306 dr.unsigned_abs() + dg.unsigned_abs() + db.unsigned_abs()
307}
308
309#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct MaskRegion {
312 pub x: u32,
314 pub y: u32,
316 pub width: u32,
318 pub height: u32,
320}
321
322impl MaskRegion {
323 #[must_use]
325 pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
326 Self {
327 x,
328 y,
329 width,
330 height,
331 }
332 }
333
334 #[must_use]
336 pub const fn contains(&self, px: u32, py: u32) -> bool {
337 px >= self.x && px < self.x + self.width && py >= self.y && py < self.y + self.height
338 }
339}
340
341#[derive(Debug, Clone, Default)]
343pub struct ScreenshotComparison {
344 pub threshold: f64,
346 pub max_diff_pixels: Option<usize>,
348 pub max_diff_pixel_ratio: Option<f64>,
350 pub mask_regions: Vec<MaskRegion>,
352}
353
354impl ScreenshotComparison {
355 #[must_use]
357 pub fn new() -> Self {
358 Self::default()
359 }
360
361 #[must_use]
363 pub const fn with_threshold(mut self, threshold: f64) -> Self {
364 self.threshold = threshold;
365 self
366 }
367
368 #[must_use]
370 pub const fn with_max_diff_pixels(mut self, pixels: usize) -> Self {
371 self.max_diff_pixels = Some(pixels);
372 self
373 }
374
375 #[must_use]
377 pub const fn with_max_diff_pixel_ratio(mut self, ratio: f64) -> Self {
378 self.max_diff_pixel_ratio = Some(ratio);
379 self
380 }
381
382 #[must_use]
384 pub fn with_mask(mut self, mask: MaskRegion) -> Self {
385 self.mask_regions.push(mask);
386 self
387 }
388}
389
390#[must_use]
397pub fn perceptual_diff(a: Rgba<u8>, b: Rgba<u8>) -> f64 {
398 let Rgba([r1, g1, b1, _]) = a;
399 let Rgba([r2, g2, b2, _]) = b;
400
401 let dr = (f64::from(r1) - f64::from(r2)) * 0.299;
404 let dg = (f64::from(g1) - f64::from(g2)) * 0.587;
405 let db = (f64::from(b1) - f64::from(b2)) * 0.114;
406
407 (dr * dr + dg * dg + db * db).sqrt()
408}
409
410#[cfg(test)]
411#[allow(clippy::unwrap_used, clippy::expect_used)]
412mod tests {
413 use super::*;
414 use image::ImageEncoder;
415
416 #[test]
417 fn test_config_defaults() {
418 let config = VisualRegressionConfig::default();
419 assert!((config.threshold - 0.01).abs() < f64::EPSILON);
420 assert_eq!(config.color_threshold, 10);
421 assert_eq!(config.baseline_dir, "__baselines__");
422 assert_eq!(config.diff_dir, "__diffs__");
423 assert!(!config.update_baselines);
424 }
425
426 #[test]
427 fn test_config_builder() {
428 let config = VisualRegressionConfig::default()
429 .with_threshold(0.05)
430 .with_color_threshold(20);
431 assert!((config.threshold - 0.05).abs() < f64::EPSILON);
432 assert_eq!(config.color_threshold, 20);
433 }
434
435 #[test]
436 fn test_config_with_baseline_dir() {
437 let config = VisualRegressionConfig::default().with_baseline_dir("my_baselines");
438 assert_eq!(config.baseline_dir, "my_baselines");
439 }
440
441 #[test]
442 fn test_config_with_update_baselines() {
443 let config = VisualRegressionConfig::default().with_update_baselines(true);
444 assert!(config.update_baselines);
445 }
446
447 #[test]
448 fn test_tester_config_accessor() {
449 let config = VisualRegressionConfig::default().with_threshold(0.02);
450 let tester = VisualRegressionTester::new(config);
451 assert!((tester.config().threshold - 0.02).abs() < f64::EPSILON);
452 }
453
454 #[test]
455 fn test_tester_default() {
456 let tester = VisualRegressionTester::default();
457 assert!((tester.config().threshold - 0.01).abs() < f64::EPSILON);
458 }
459
460 #[test]
461 fn test_perceptual_diff() {
462 let white = Rgba([255, 255, 255, 255]);
463 let black = Rgba([0, 0, 0, 255]);
464 let red = Rgba([255, 0, 0, 255]);
465
466 assert!((perceptual_diff(white, white) - 0.0).abs() < f64::EPSILON);
468
469 let wb_diff = perceptual_diff(white, black);
471 assert!(wb_diff > 0.0);
472
473 let rb_diff = perceptual_diff(red, black);
475 assert!(rb_diff < wb_diff);
476 }
477
478 #[test]
479 fn test_image_diff_result_is_identical() {
480 let result = ImageDiffResult {
481 matches: true,
482 diff_pixel_count: 0,
483 total_pixels: 100,
484 diff_percentage: 0.0,
485 max_color_diff: 0,
486 avg_color_diff: 0.0,
487 diff_image: None,
488 };
489 assert!(result.is_identical());
490
491 let result2 = ImageDiffResult {
492 matches: false,
493 diff_pixel_count: 5,
494 total_pixels: 100,
495 diff_percentage: 5.0,
496 max_color_diff: 100,
497 avg_color_diff: 50.0,
498 diff_image: None,
499 };
500 assert!(!result2.is_identical());
501 }
502
503 #[test]
504 fn test_identical_images() {
505 let mut img = image::RgbaImage::new(2, 2);
507 for pixel in img.pixels_mut() {
508 *pixel = Rgba([255, 0, 0, 255]);
509 }
510
511 let mut buffer = Vec::new();
512 let encoder = image::codecs::png::PngEncoder::new(&mut buffer);
513 encoder
514 .write_image(img.as_raw(), 2, 2, image::ExtendedColorType::Rgba8)
515 .unwrap();
516
517 let tester = VisualRegressionTester::default();
518 let result = tester.compare_images(&buffer, &buffer).unwrap();
519
520 assert!(result.is_identical());
521 assert!(result.matches);
522 assert_eq!(result.diff_pixel_count, 0);
523 }
524
525 #[test]
526 fn test_different_images() {
527 let mut img1 = image::RgbaImage::new(2, 2);
529 let mut img2 = image::RgbaImage::new(2, 2);
530
531 for pixel in img1.pixels_mut() {
532 *pixel = Rgba([255, 0, 0, 255]); }
534 for pixel in img2.pixels_mut() {
535 *pixel = Rgba([0, 255, 0, 255]); }
537
538 let mut buffer1 = Vec::new();
539 let mut buffer2 = Vec::new();
540
541 let encoder1 = image::codecs::png::PngEncoder::new(&mut buffer1);
542 encoder1
543 .write_image(img1.as_raw(), 2, 2, image::ExtendedColorType::Rgba8)
544 .unwrap();
545
546 let encoder2 = image::codecs::png::PngEncoder::new(&mut buffer2);
547 encoder2
548 .write_image(img2.as_raw(), 2, 2, image::ExtendedColorType::Rgba8)
549 .unwrap();
550
551 let tester = VisualRegressionTester::default();
552 let result = tester.compare_images(&buffer1, &buffer2).unwrap();
553
554 assert!(!result.is_identical());
555 assert!(!result.matches);
556 assert_eq!(result.diff_pixel_count, 4);
557 assert!(result.diff_percentage > 99.0);
558 }
559
560 #[test]
561 fn test_within_threshold() {
562 let result = ImageDiffResult {
563 matches: true,
564 diff_pixel_count: 10,
565 total_pixels: 10000,
566 diff_percentage: 0.1,
567 max_color_diff: 50,
568 avg_color_diff: 25.0,
569 diff_image: None,
570 };
571
572 assert!(result.within_threshold(0.01)); assert!(!result.within_threshold(0.0005)); }
575
576 #[test]
577 fn test_dimension_mismatch() {
578 let img1 = image::RgbaImage::new(2, 2);
579 let img2 = image::RgbaImage::new(3, 3);
580
581 let mut buffer1 = Vec::new();
582 let mut buffer2 = Vec::new();
583
584 let encoder1 = image::codecs::png::PngEncoder::new(&mut buffer1);
585 encoder1
586 .write_image(img1.as_raw(), 2, 2, image::ExtendedColorType::Rgba8)
587 .unwrap();
588
589 let encoder2 = image::codecs::png::PngEncoder::new(&mut buffer2);
590 encoder2
591 .write_image(img2.as_raw(), 3, 3, image::ExtendedColorType::Rgba8)
592 .unwrap();
593
594 let tester = VisualRegressionTester::default();
595 let result = tester.compare_images(&buffer1, &buffer2);
596
597 assert!(result.is_err());
598 }
599
600 #[test]
601 fn test_pixel_diff() {
602 let white = Rgba([255, 255, 255, 255]);
603 let black = Rgba([0, 0, 0, 255]);
604 let red = Rgba([255, 0, 0, 255]);
605
606 assert_eq!(pixel_diff(white, white), 0);
607 assert_eq!(pixel_diff(white, black), 255 * 3);
608 assert_eq!(pixel_diff(red, black), 255);
609 }
610
611 #[test]
612 #[allow(clippy::cast_possible_truncation)]
613 fn test_small_difference_within_threshold() {
614 let mut img1 = image::RgbaImage::new(10, 10);
616 let mut img2 = image::RgbaImage::new(10, 10);
617
618 for (i, pixel) in img1.pixels_mut().enumerate() {
619 *pixel = Rgba([100, 100, 100, 255]);
620 if i == 0 {
622 img2.put_pixel(0, 0, Rgba([105, 105, 105, 255])); } else {
624 img2.put_pixel((i % 10) as u32, (i / 10) as u32, Rgba([100, 100, 100, 255]));
625 }
626 }
627
628 let mut buffer1 = Vec::new();
629 let mut buffer2 = Vec::new();
630
631 let encoder1 = image::codecs::png::PngEncoder::new(&mut buffer1);
632 encoder1
633 .write_image(img1.as_raw(), 10, 10, image::ExtendedColorType::Rgba8)
634 .unwrap();
635
636 let encoder2 = image::codecs::png::PngEncoder::new(&mut buffer2);
637 encoder2
638 .write_image(img2.as_raw(), 10, 10, image::ExtendedColorType::Rgba8)
639 .unwrap();
640
641 let tester = VisualRegressionTester::default();
643 let result = tester.compare_images(&buffer1, &buffer2).unwrap();
644
645 assert!(result.matches); }
647
648 #[test]
649 fn test_compare_against_baseline_missing() {
650 let config =
651 VisualRegressionConfig::default().with_baseline_dir("/tmp/nonexistent_baselines_12345");
652 let tester = VisualRegressionTester::new(config);
653
654 let img = image::RgbaImage::new(2, 2);
656 let mut buffer = Vec::new();
657 let encoder = image::codecs::png::PngEncoder::new(&mut buffer);
658 encoder
659 .write_image(img.as_raw(), 2, 2, image::ExtendedColorType::Rgba8)
660 .unwrap();
661
662 let result = tester.compare_against_baseline("missing_test", &buffer);
663 assert!(result.is_err());
664 }
665
666 #[test]
667 fn test_compare_against_baseline_with_update() {
668 use std::fs;
669 let temp_dir = std::env::temp_dir().join("vr_test_update_baselines");
670 let _ = fs::remove_dir_all(&temp_dir); let config = VisualRegressionConfig::default()
673 .with_baseline_dir(temp_dir.to_string_lossy())
674 .with_update_baselines(true);
675 let tester = VisualRegressionTester::new(config);
676
677 let mut img = image::RgbaImage::new(2, 2);
679 for pixel in img.pixels_mut() {
680 *pixel = Rgba([100, 100, 100, 255]);
681 }
682 let mut buffer = Vec::new();
683 let encoder = image::codecs::png::PngEncoder::new(&mut buffer);
684 encoder
685 .write_image(img.as_raw(), 2, 2, image::ExtendedColorType::Rgba8)
686 .unwrap();
687
688 let result = tester
690 .compare_against_baseline("update_test", &buffer)
691 .unwrap();
692 assert!(result.matches);
693 assert!(temp_dir.join("update_test.png").exists());
694
695 let result2 = tester
697 .compare_against_baseline("update_test", &buffer)
698 .unwrap();
699 assert!(result2.matches);
700
701 let _ = fs::remove_dir_all(&temp_dir);
703 }
704
705 #[test]
706 fn test_compare_against_baseline_mismatch_saves_diff() {
707 use std::fs;
708 let temp_dir = std::env::temp_dir().join("vr_test_diff_save");
709 let _ = fs::remove_dir_all(&temp_dir);
710 let diff_dir = std::env::temp_dir().join("vr_test_diff_save_diffs");
711 let _ = fs::remove_dir_all(&diff_dir);
712
713 fs::create_dir_all(&temp_dir).unwrap();
714
715 let mut config = VisualRegressionConfig::default()
716 .with_baseline_dir(temp_dir.to_string_lossy())
717 .with_threshold(0.0001) .with_color_threshold(0); config.diff_dir = diff_dir.to_string_lossy().to_string();
720 let tester = VisualRegressionTester::new(config);
721
722 let mut img1 = image::RgbaImage::new(2, 2);
724 for pixel in img1.pixels_mut() {
725 *pixel = Rgba([255, 0, 0, 255]);
726 }
727 let mut buffer1 = Vec::new();
728 let encoder = image::codecs::png::PngEncoder::new(&mut buffer1);
729 encoder
730 .write_image(img1.as_raw(), 2, 2, image::ExtendedColorType::Rgba8)
731 .unwrap();
732 fs::write(temp_dir.join("diff_test.png"), &buffer1).unwrap();
733
734 let mut img2 = image::RgbaImage::new(2, 2);
736 for pixel in img2.pixels_mut() {
737 *pixel = Rgba([0, 255, 0, 255]);
738 }
739 let mut buffer2 = Vec::new();
740 let encoder = image::codecs::png::PngEncoder::new(&mut buffer2);
741 encoder
742 .write_image(img2.as_raw(), 2, 2, image::ExtendedColorType::Rgba8)
743 .unwrap();
744
745 let result = tester
747 .compare_against_baseline("diff_test", &buffer2)
748 .unwrap();
749 assert!(!result.matches);
750 assert!(diff_dir.join("diff_test_diff.png").exists());
751
752 let _ = fs::remove_dir_all(&temp_dir);
754 let _ = fs::remove_dir_all(&diff_dir);
755 }
756
757 #[test]
758 fn test_diff_image_generation() {
759 let mut img1 = image::RgbaImage::new(4, 4);
761 let mut img2 = image::RgbaImage::new(4, 4);
762
763 for pixel in img1.pixels_mut() {
764 *pixel = Rgba([0, 0, 0, 255]); }
766 for pixel in img2.pixels_mut() {
767 *pixel = Rgba([255, 255, 255, 255]); }
769
770 let mut buffer1 = Vec::new();
771 let mut buffer2 = Vec::new();
772
773 let encoder1 = image::codecs::png::PngEncoder::new(&mut buffer1);
774 encoder1
775 .write_image(img1.as_raw(), 4, 4, image::ExtendedColorType::Rgba8)
776 .unwrap();
777
778 let encoder2 = image::codecs::png::PngEncoder::new(&mut buffer2);
779 encoder2
780 .write_image(img2.as_raw(), 4, 4, image::ExtendedColorType::Rgba8)
781 .unwrap();
782
783 let config = VisualRegressionConfig::default().with_threshold(0.0);
784 let tester = VisualRegressionTester::new(config);
785 let result = tester.compare_images(&buffer1, &buffer2).unwrap();
786
787 assert!(!result.matches);
788 assert!(result.diff_image.is_some());
789 assert!(!result.diff_image.as_ref().unwrap().is_empty());
790 }
791
792 #[test]
793 fn test_avg_color_diff() {
794 let mut img1 = image::RgbaImage::new(2, 2);
796 let mut img2 = image::RgbaImage::new(2, 2);
797
798 for pixel in img1.pixels_mut() {
799 *pixel = Rgba([100, 100, 100, 255]);
800 }
801 for pixel in img2.pixels_mut() {
802 *pixel = Rgba([200, 100, 100, 255]); }
804
805 let mut buffer1 = Vec::new();
806 let mut buffer2 = Vec::new();
807
808 let encoder1 = image::codecs::png::PngEncoder::new(&mut buffer1);
809 encoder1
810 .write_image(img1.as_raw(), 2, 2, image::ExtendedColorType::Rgba8)
811 .unwrap();
812
813 let encoder2 = image::codecs::png::PngEncoder::new(&mut buffer2);
814 encoder2
815 .write_image(img2.as_raw(), 2, 2, image::ExtendedColorType::Rgba8)
816 .unwrap();
817
818 let config = VisualRegressionConfig::default().with_color_threshold(0);
819 let tester = VisualRegressionTester::new(config);
820 let result = tester.compare_images(&buffer1, &buffer2).unwrap();
821
822 assert_eq!(result.diff_pixel_count, 4);
823 assert_eq!(result.max_color_diff, 100);
824 assert!((result.avg_color_diff - 100.0).abs() < f64::EPSILON);
825 }
826
827 #[test]
828 fn test_invalid_image_decode() {
829 let tester = VisualRegressionTester::default();
830 let invalid_data = vec![0, 1, 2, 3, 4]; let result = tester.compare_images(&invalid_data, &invalid_data);
833 assert!(result.is_err());
834 }
835
836 #[test]
843 #[allow(clippy::cast_possible_truncation, clippy::items_after_statements)]
844 fn test_hdr_content_handling() {
845 let hdr_pixel_value: u16 = 512; let sdr_normalized = hdr_pixel_value.min(255) as u8; assert_eq!(sdr_normalized, 255, "HDR values clamped to SDR range");
851
852 #[allow(clippy::cast_sign_loss)]
854 fn tone_map_hdr(value: f32, max_luminance: f32) -> u8 {
855 let normalized = value / max_luminance;
856 let gamma_corrected = normalized.powf(1.0 / 2.2);
857 (gamma_corrected.clamp(0.0, 1.0) * 255.0) as u8
858 }
859
860 let hdr_white = tone_map_hdr(1000.0, 1000.0);
861 let hdr_mid = tone_map_hdr(500.0, 1000.0);
862
863 assert!(
864 hdr_white > hdr_mid,
865 "Tone mapping preserves relative brightness"
866 );
867 assert_eq!(hdr_white, 255, "Max HDR maps to max SDR");
868 }
869
870 #[test]
872 #[allow(clippy::cast_possible_truncation)]
873 fn test_color_depth_normalization() {
874 let ten_bit_value: u16 = 1023; let eight_bit_value = (ten_bit_value >> 2) as u8; assert_eq!(eight_bit_value, 255, "10-bit normalized to 8-bit");
879
880 let sixteen_bit_value: u16 = 65535; let eight_bit_from_16 = (sixteen_bit_value >> 8) as u8;
883
884 assert_eq!(eight_bit_from_16, 255, "16-bit normalized to 8-bit");
885 }
886
887 mod h0_visual_regression_tests {
892 use super::*;
893
894 #[test]
895 fn h0_visual_01_config_default_threshold() {
896 let config = VisualRegressionConfig::default();
897 assert!((config.threshold - 0.01).abs() < f64::EPSILON);
898 }
899
900 #[test]
901 fn h0_visual_02_config_default_color_threshold() {
902 let config = VisualRegressionConfig::default();
903 assert_eq!(config.color_threshold, 10);
904 }
905
906 #[test]
907 fn h0_visual_03_config_default_baseline_dir() {
908 let config = VisualRegressionConfig::default();
909 assert_eq!(config.baseline_dir, "__baselines__");
910 }
911
912 #[test]
913 fn h0_visual_04_config_default_diff_dir() {
914 let config = VisualRegressionConfig::default();
915 assert_eq!(config.diff_dir, "__diffs__");
916 }
917
918 #[test]
919 fn h0_visual_05_config_default_update_baselines() {
920 let config = VisualRegressionConfig::default();
921 assert!(!config.update_baselines);
922 }
923
924 #[test]
925 fn h0_visual_06_config_with_threshold() {
926 let config = VisualRegressionConfig::default().with_threshold(0.05);
927 assert!((config.threshold - 0.05).abs() < f64::EPSILON);
928 }
929
930 #[test]
931 fn h0_visual_07_config_with_color_threshold() {
932 let config = VisualRegressionConfig::default().with_color_threshold(25);
933 assert_eq!(config.color_threshold, 25);
934 }
935
936 #[test]
937 fn h0_visual_08_config_with_baseline_dir() {
938 let config = VisualRegressionConfig::default().with_baseline_dir("custom_baselines");
939 assert_eq!(config.baseline_dir, "custom_baselines");
940 }
941
942 #[test]
943 fn h0_visual_09_config_with_update_baselines() {
944 let config = VisualRegressionConfig::default().with_update_baselines(true);
945 assert!(config.update_baselines);
946 }
947
948 #[test]
949 fn h0_visual_10_tester_default() {
950 let tester = VisualRegressionTester::default();
951 assert!((tester.config.threshold - 0.01).abs() < f64::EPSILON);
952 }
953 }
954
955 mod h0_image_diff_result_tests {
956 use super::*;
957
958 #[test]
959 fn h0_visual_11_diff_result_is_identical_true() {
960 let result = ImageDiffResult {
961 matches: true,
962 diff_pixel_count: 0,
963 total_pixels: 100,
964 diff_percentage: 0.0,
965 max_color_diff: 0,
966 avg_color_diff: 0.0,
967 diff_image: None,
968 };
969 assert!(result.is_identical());
970 }
971
972 #[test]
973 fn h0_visual_12_diff_result_is_identical_false() {
974 let result = ImageDiffResult {
975 matches: true,
976 diff_pixel_count: 1,
977 total_pixels: 100,
978 diff_percentage: 1.0,
979 max_color_diff: 50,
980 avg_color_diff: 50.0,
981 diff_image: None,
982 };
983 assert!(!result.is_identical());
984 }
985
986 #[test]
987 fn h0_visual_13_diff_result_within_threshold_pass() {
988 let result = ImageDiffResult {
989 matches: true,
990 diff_pixel_count: 1,
991 total_pixels: 100,
992 diff_percentage: 1.0,
993 max_color_diff: 10,
994 avg_color_diff: 10.0,
995 diff_image: None,
996 };
997 assert!(result.within_threshold(0.02)); }
999
1000 #[test]
1001 fn h0_visual_14_diff_result_within_threshold_fail() {
1002 let result = ImageDiffResult {
1003 matches: false,
1004 diff_pixel_count: 10,
1005 total_pixels: 100,
1006 diff_percentage: 10.0,
1007 max_color_diff: 100,
1008 avg_color_diff: 80.0,
1009 diff_image: None,
1010 };
1011 assert!(!result.within_threshold(0.05)); }
1013
1014 #[test]
1015 fn h0_visual_15_diff_result_percentage_calculation() {
1016 let result = ImageDiffResult {
1017 matches: false,
1018 diff_pixel_count: 50,
1019 total_pixels: 100,
1020 diff_percentage: 50.0,
1021 max_color_diff: 255,
1022 avg_color_diff: 128.0,
1023 diff_image: None,
1024 };
1025 assert!((result.diff_percentage - 50.0).abs() < f64::EPSILON);
1026 }
1027
1028 #[test]
1029 fn h0_visual_16_diff_result_max_color_diff() {
1030 let result = ImageDiffResult {
1031 matches: false,
1032 diff_pixel_count: 5,
1033 total_pixels: 100,
1034 diff_percentage: 5.0,
1035 max_color_diff: 200,
1036 avg_color_diff: 150.0,
1037 diff_image: None,
1038 };
1039 assert_eq!(result.max_color_diff, 200);
1040 }
1041
1042 #[test]
1043 fn h0_visual_17_diff_result_avg_color_diff() {
1044 let result = ImageDiffResult {
1045 matches: false,
1046 diff_pixel_count: 5,
1047 total_pixels: 100,
1048 diff_percentage: 5.0,
1049 max_color_diff: 200,
1050 avg_color_diff: 125.5,
1051 diff_image: None,
1052 };
1053 assert!((result.avg_color_diff - 125.5).abs() < f64::EPSILON);
1054 }
1055
1056 #[test]
1057 fn h0_visual_18_diff_result_with_diff_image() {
1058 let result = ImageDiffResult {
1059 matches: false,
1060 diff_pixel_count: 10,
1061 total_pixels: 100,
1062 diff_percentage: 10.0,
1063 max_color_diff: 255,
1064 avg_color_diff: 200.0,
1065 diff_image: Some(vec![1, 2, 3, 4]),
1066 };
1067 assert!(result.diff_image.is_some());
1068 }
1069
1070 #[test]
1071 fn h0_visual_19_diff_result_matches_field() {
1072 let result = ImageDiffResult {
1073 matches: true,
1074 diff_pixel_count: 0,
1075 total_pixels: 100,
1076 diff_percentage: 0.0,
1077 max_color_diff: 0,
1078 avg_color_diff: 0.0,
1079 diff_image: None,
1080 };
1081 assert!(result.matches);
1082 }
1083
1084 #[test]
1085 fn h0_visual_20_diff_result_total_pixels() {
1086 let result = ImageDiffResult {
1087 matches: true,
1088 diff_pixel_count: 0,
1089 total_pixels: 1920 * 1080,
1090 diff_percentage: 0.0,
1091 max_color_diff: 0,
1092 avg_color_diff: 0.0,
1093 diff_image: None,
1094 };
1095 assert_eq!(result.total_pixels, 1920 * 1080);
1096 }
1097 }
1098
1099 mod h0_image_comparison_tests {
1100 use super::*;
1101 use image::Rgba;
1102
1103 fn create_test_image(width: u32, height: u32, color: Rgba<u8>) -> Vec<u8> {
1104 let mut img = image::RgbaImage::new(width, height);
1105 for pixel in img.pixels_mut() {
1106 *pixel = color;
1107 }
1108 let mut buffer = Vec::new();
1109 let encoder = image::codecs::png::PngEncoder::new(&mut buffer);
1110 encoder
1111 .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgba8)
1112 .unwrap();
1113 buffer
1114 }
1115
1116 #[test]
1117 fn h0_visual_21_compare_identical_images() {
1118 let tester = VisualRegressionTester::default();
1119 let img = create_test_image(10, 10, Rgba([128, 128, 128, 255]));
1120 let result = tester.compare_images(&img, &img).unwrap();
1121 assert!(result.matches);
1122 assert!(result.is_identical());
1123 }
1124
1125 #[test]
1126 fn h0_visual_22_compare_different_images() {
1127 let tester = VisualRegressionTester::new(
1128 VisualRegressionConfig::default()
1129 .with_threshold(0.0)
1130 .with_color_threshold(0),
1131 );
1132 let img1 = create_test_image(10, 10, Rgba([0, 0, 0, 255]));
1133 let img2 = create_test_image(10, 10, Rgba([255, 255, 255, 255]));
1134 let result = tester.compare_images(&img1, &img2).unwrap();
1135 assert!(!result.matches);
1136 }
1137
1138 #[test]
1139 fn h0_visual_23_compare_within_color_threshold() {
1140 let tester = VisualRegressionTester::new(
1141 VisualRegressionConfig::default().with_color_threshold(50),
1142 );
1143 let img1 = create_test_image(10, 10, Rgba([100, 100, 100, 255]));
1144 let img2 = create_test_image(10, 10, Rgba([110, 110, 110, 255])); let result = tester.compare_images(&img1, &img2).unwrap();
1146 assert!(result.matches);
1147 }
1148
1149 #[test]
1150 fn h0_visual_24_compare_exceeds_color_threshold() {
1151 let tester = VisualRegressionTester::new(
1152 VisualRegressionConfig::default()
1153 .with_color_threshold(5)
1154 .with_threshold(0.0),
1155 );
1156 let img1 = create_test_image(10, 10, Rgba([100, 100, 100, 255]));
1157 let img2 = create_test_image(10, 10, Rgba([150, 150, 150, 255])); let result = tester.compare_images(&img1, &img2).unwrap();
1159 assert!(!result.matches);
1160 }
1161
1162 #[test]
1163 fn h0_visual_25_compare_within_pixel_threshold() {
1164 let tester = VisualRegressionTester::new(
1165 VisualRegressionConfig::default()
1166 .with_threshold(0.5) .with_color_threshold(0),
1168 );
1169 let mut img1 = image::RgbaImage::new(10, 10);
1171 let mut img2 = image::RgbaImage::new(10, 10);
1172 for (i, pixel) in img1.pixels_mut().enumerate() {
1173 *pixel = if i < 70 {
1174 Rgba([0, 0, 0, 255])
1175 } else {
1176 Rgba([255, 255, 255, 255])
1177 };
1178 }
1179 for pixel in img2.pixels_mut() {
1180 *pixel = Rgba([0, 0, 0, 255]);
1181 }
1182 let mut buf1 = Vec::new();
1183 let mut buf2 = Vec::new();
1184 image::codecs::png::PngEncoder::new(&mut buf1)
1185 .write_image(img1.as_raw(), 10, 10, image::ExtendedColorType::Rgba8)
1186 .unwrap();
1187 image::codecs::png::PngEncoder::new(&mut buf2)
1188 .write_image(img2.as_raw(), 10, 10, image::ExtendedColorType::Rgba8)
1189 .unwrap();
1190 let result = tester.compare_images(&buf1, &buf2).unwrap();
1191 assert!(result.matches); }
1193
1194 #[test]
1195 fn h0_visual_26_compare_size_mismatch() {
1196 let tester = VisualRegressionTester::default();
1197 let img1 = create_test_image(10, 10, Rgba([128, 128, 128, 255]));
1198 let img2 = create_test_image(20, 20, Rgba([128, 128, 128, 255]));
1199 let result = tester.compare_images(&img1, &img2);
1200 assert!(result.is_err());
1201 }
1202
1203 #[test]
1204 fn h0_visual_27_compare_invalid_image() {
1205 let tester = VisualRegressionTester::default();
1206 let invalid = vec![0, 1, 2, 3];
1207 let valid = create_test_image(10, 10, Rgba([128, 128, 128, 255]));
1208 let result = tester.compare_images(&invalid, &valid);
1209 assert!(result.is_err());
1210 }
1211
1212 #[test]
1213 fn h0_visual_28_diff_image_generated() {
1214 let tester = VisualRegressionTester::new(
1215 VisualRegressionConfig::default()
1216 .with_threshold(0.0)
1217 .with_color_threshold(0),
1218 );
1219 let img1 = create_test_image(10, 10, Rgba([0, 0, 0, 255]));
1220 let img2 = create_test_image(10, 10, Rgba([255, 0, 0, 255]));
1221 let result = tester.compare_images(&img1, &img2).unwrap();
1222 assert!(result.diff_image.is_some());
1223 }
1224
1225 #[test]
1226 fn h0_visual_29_tester_new_with_config() {
1227 let config = VisualRegressionConfig::default().with_threshold(0.1);
1228 let tester = VisualRegressionTester::new(config);
1229 assert!((tester.config.threshold - 0.1).abs() < f64::EPSILON);
1230 }
1231
1232 #[test]
1233 fn h0_visual_30_compare_red_channel_only_diff() {
1234 let tester = VisualRegressionTester::new(
1235 VisualRegressionConfig::default()
1236 .with_threshold(0.0)
1237 .with_color_threshold(0),
1238 );
1239 let img1 = create_test_image(10, 10, Rgba([100, 100, 100, 255]));
1240 let img2 = create_test_image(10, 10, Rgba([200, 100, 100, 255])); let result = tester.compare_images(&img1, &img2).unwrap();
1242 assert!(!result.matches);
1243 assert_eq!(result.max_color_diff, 100);
1244 }
1245 }
1246
1247 mod h0_screenshot_comparison_tests {
1248 use super::*;
1249
1250 #[test]
1251 fn h0_visual_31_screenshot_comparison_default() {
1252 let comparison = ScreenshotComparison::default();
1253 assert!((comparison.threshold - 0.0).abs() < f64::EPSILON);
1254 }
1255
1256 #[test]
1257 fn h0_visual_32_screenshot_comparison_with_threshold() {
1258 let comparison = ScreenshotComparison::new().with_threshold(0.05);
1259 assert!((comparison.threshold - 0.05).abs() < f64::EPSILON);
1260 }
1261
1262 #[test]
1263 fn h0_visual_33_screenshot_comparison_with_max_diff_pixels() {
1264 let comparison = ScreenshotComparison::new().with_max_diff_pixels(100);
1265 assert_eq!(comparison.max_diff_pixels, Some(100));
1266 }
1267
1268 #[test]
1269 fn h0_visual_34_screenshot_comparison_with_max_diff_pixel_ratio() {
1270 let comparison = ScreenshotComparison::new().with_max_diff_pixel_ratio(0.1);
1271 assert!((comparison.max_diff_pixel_ratio.unwrap() - 0.1).abs() < f64::EPSILON);
1272 }
1273
1274 #[test]
1275 fn h0_visual_35_screenshot_comparison_with_mask() {
1276 let mask = MaskRegion::new(10, 20, 100, 50);
1277 let comparison = ScreenshotComparison::new().with_mask(mask);
1278 assert_eq!(comparison.mask_regions.len(), 1);
1279 }
1280
1281 #[test]
1282 fn h0_visual_36_screenshot_comparison_multiple_masks() {
1283 let comparison = ScreenshotComparison::new()
1284 .with_mask(MaskRegion::new(0, 0, 50, 50))
1285 .with_mask(MaskRegion::new(100, 100, 50, 50));
1286 assert_eq!(comparison.mask_regions.len(), 2);
1287 }
1288
1289 #[test]
1290 fn h0_visual_37_mask_region_creation() {
1291 let mask = MaskRegion::new(10, 20, 100, 50);
1292 assert_eq!(mask.x, 10);
1293 assert_eq!(mask.y, 20);
1294 assert_eq!(mask.width, 100);
1295 assert_eq!(mask.height, 50);
1296 }
1297
1298 #[test]
1299 fn h0_visual_38_mask_region_contains_inside() {
1300 let mask = MaskRegion::new(0, 0, 100, 100);
1301 assert!(mask.contains(50, 50));
1302 }
1303
1304 #[test]
1305 fn h0_visual_39_mask_region_contains_outside() {
1306 let mask = MaskRegion::new(0, 0, 100, 100);
1307 assert!(!mask.contains(150, 150));
1308 }
1309
1310 #[test]
1311 fn h0_visual_40_mask_region_contains_edge() {
1312 let mask = MaskRegion::new(0, 0, 100, 100);
1313 assert!(mask.contains(0, 0));
1314 assert!(mask.contains(99, 99));
1315 }
1316 }
1317
1318 mod h0_additional_tests {
1319 use super::*;
1320
1321 #[test]
1322 fn h0_visual_41_config_clone() {
1323 let config = VisualRegressionConfig::default().with_threshold(0.05);
1324 let cloned = config;
1325 assert!((cloned.threshold - 0.05).abs() < f64::EPSILON);
1326 }
1327
1328 #[test]
1329 fn h0_visual_42_config_debug() {
1330 let config = VisualRegressionConfig::default();
1331 let debug = format!("{:?}", config);
1332 assert!(debug.contains("VisualRegressionConfig"));
1333 }
1334
1335 #[test]
1336 fn h0_visual_43_diff_result_clone() {
1337 let result = ImageDiffResult {
1338 matches: true,
1339 diff_pixel_count: 0,
1340 total_pixels: 100,
1341 diff_percentage: 0.0,
1342 max_color_diff: 0,
1343 avg_color_diff: 0.0,
1344 diff_image: None,
1345 };
1346 let cloned = result;
1347 assert!(cloned.matches);
1348 }
1349
1350 #[test]
1351 fn h0_visual_44_diff_result_debug() {
1352 let result = ImageDiffResult {
1353 matches: true,
1354 diff_pixel_count: 0,
1355 total_pixels: 100,
1356 diff_percentage: 0.0,
1357 max_color_diff: 0,
1358 avg_color_diff: 0.0,
1359 diff_image: None,
1360 };
1361 let debug = format!("{:?}", result);
1362 assert!(debug.contains("ImageDiffResult"));
1363 }
1364
1365 #[test]
1366 fn h0_visual_45_tester_clone() {
1367 let tester = VisualRegressionTester::default();
1368 let cloned = tester.clone();
1369 assert!((cloned.config.threshold - tester.config.threshold).abs() < f64::EPSILON);
1370 }
1371
1372 #[test]
1373 fn h0_visual_46_tester_debug() {
1374 let tester = VisualRegressionTester::default();
1375 let debug = format!("{:?}", tester);
1376 assert!(debug.contains("VisualRegressionTester"));
1377 }
1378
1379 #[test]
1380 fn h0_visual_47_screenshot_comparison_new() {
1381 let comparison = ScreenshotComparison::new();
1382 assert!(comparison.mask_regions.is_empty());
1383 }
1384
1385 #[test]
1386 fn h0_visual_48_screenshot_comparison_clone() {
1387 let comparison = ScreenshotComparison::new().with_threshold(0.1);
1388 let cloned = comparison;
1389 assert!((cloned.threshold - 0.1).abs() < f64::EPSILON);
1390 }
1391
1392 #[test]
1393 fn h0_visual_49_mask_region_clone() {
1394 let mask = MaskRegion::new(10, 20, 30, 40);
1395 let cloned = mask;
1396 assert_eq!(cloned.x, 10);
1397 }
1398
1399 #[test]
1400 fn h0_visual_50_mask_region_debug() {
1401 let mask = MaskRegion::new(10, 20, 30, 40);
1402 let debug = format!("{:?}", mask);
1403 assert!(debug.contains("MaskRegion"));
1404 }
1405 }
1406}