Skip to main content

jugar_probar/
visual_regression.rs

1//! Visual regression testing with real image comparison.
2//!
3//! Per spec Section 6.2: Visual Regression Testing using pure Rust image comparison.
4
5use crate::result::{ProbarError, ProbarResult};
6use image::{DynamicImage, GenericImageView, ImageEncoder, Rgba};
7use std::path::Path;
8
9/// Configuration for visual regression testing
10#[derive(Debug, Clone)]
11pub struct VisualRegressionConfig {
12    /// Difference threshold (0.0-1.0) - percentage of pixels that can differ
13    pub threshold: f64,
14    /// Per-pixel color difference threshold (0-255)
15    pub color_threshold: u8,
16    /// Directory to store baseline images
17    pub baseline_dir: String,
18    /// Directory to store diff images on failure
19    pub diff_dir: String,
20    /// Whether to update baselines automatically
21    pub update_baselines: bool,
22}
23
24impl Default for VisualRegressionConfig {
25    fn default() -> Self {
26        Self {
27            threshold: 0.01,     // 1% of pixels can differ
28            color_threshold: 10, // Allow minor color variations
29            baseline_dir: String::from("__baselines__"),
30            diff_dir: String::from("__diffs__"),
31            update_baselines: false,
32        }
33    }
34}
35
36impl VisualRegressionConfig {
37    /// Set the threshold
38    #[must_use]
39    pub const fn with_threshold(mut self, threshold: f64) -> Self {
40        self.threshold = threshold;
41        self
42    }
43
44    /// Set the color threshold
45    #[must_use]
46    pub const fn with_color_threshold(mut self, threshold: u8) -> Self {
47        self.color_threshold = threshold;
48        self
49    }
50
51    /// Set the baseline directory
52    #[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    /// Enable baseline updates
59    #[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/// Result of comparing two images
67#[derive(Debug, Clone)]
68pub struct ImageDiffResult {
69    /// Whether images match within threshold
70    pub matches: bool,
71    /// Number of pixels that differ
72    pub diff_pixel_count: usize,
73    /// Total number of pixels compared
74    pub total_pixels: usize,
75    /// Percentage of pixels that differ (0.0-100.0)
76    pub diff_percentage: f64,
77    /// Maximum color difference found
78    pub max_color_diff: u32,
79    /// Average color difference for differing pixels
80    pub avg_color_diff: f64,
81    /// Diff image data (PNG encoded, highlights differences in red)
82    pub diff_image: Option<Vec<u8>>,
83}
84
85impl ImageDiffResult {
86    /// Check if images are identical (no differences)
87    #[must_use]
88    pub const fn is_identical(&self) -> bool {
89        self.diff_pixel_count == 0
90    }
91
92    /// Check if difference is within threshold
93    #[must_use]
94    pub fn within_threshold(&self, threshold: f64) -> bool {
95        self.diff_percentage <= threshold * 100.0
96    }
97}
98
99/// Visual regression tester
100#[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    /// Create a new tester with configuration
113    #[must_use]
114    pub const fn new(config: VisualRegressionConfig) -> Self {
115        Self { config }
116    }
117
118    /// Compare two images from byte arrays (PNG format)
119    ///
120    /// # Errors
121    ///
122    /// Returns error if images cannot be decoded
123    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    /// Compare two `DynamicImage` instances
138    ///
139    /// # Errors
140    ///
141    /// Returns error if images have different dimensions
142    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        // Check dimensions match
151        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        // Create diff image
165        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                    // Highlight difference in red on diff image
183                    diff_img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
184                } else {
185                    // Copy original pixel with reduced opacity
186                    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        // Encode diff image to PNG
209        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    /// Compare screenshot against baseline file
239    ///
240    /// # Errors
241    ///
242    /// Returns error if baseline doesn't exist or comparison fails
243    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                // Create baseline
253                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        // Save diff image if comparison failed
274        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        // Update baseline if configured
283        if self.config.update_baselines && !result.matches {
284            std::fs::write(&baseline_path, screenshot)?;
285        }
286
287        Ok(result)
288    }
289
290    /// Get configuration
291    #[must_use]
292    pub const fn config(&self) -> &VisualRegressionConfig {
293        &self.config
294    }
295}
296
297/// Calculate pixel difference (sum of RGB channel differences)
298fn 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/// Mask region for screenshot comparison - excludes dynamic areas from comparison
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct MaskRegion {
312    /// X coordinate of top-left corner
313    pub x: u32,
314    /// Y coordinate of top-left corner
315    pub y: u32,
316    /// Width of mask region
317    pub width: u32,
318    /// Height of mask region
319    pub height: u32,
320}
321
322impl MaskRegion {
323    /// Create a new mask region
324    #[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    /// Check if a point is within this mask region
335    #[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/// Screenshot comparison configuration (Playwright API parity)
342#[derive(Debug, Clone, Default)]
343pub struct ScreenshotComparison {
344    /// Threshold for comparison (0.0-1.0)
345    pub threshold: f64,
346    /// Maximum number of pixels that can differ
347    pub max_diff_pixels: Option<usize>,
348    /// Maximum ratio of pixels that can differ (0.0-1.0)
349    pub max_diff_pixel_ratio: Option<f64>,
350    /// Regions to mask (exclude from comparison)
351    pub mask_regions: Vec<MaskRegion>,
352}
353
354impl ScreenshotComparison {
355    /// Create a new screenshot comparison config
356    #[must_use]
357    pub fn new() -> Self {
358        Self::default()
359    }
360
361    /// Set threshold for comparison
362    #[must_use]
363    pub const fn with_threshold(mut self, threshold: f64) -> Self {
364        self.threshold = threshold;
365        self
366    }
367
368    /// Set maximum number of differing pixels
369    #[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    /// Set maximum ratio of differing pixels
376    #[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    /// Add a mask region to exclude from comparison
383    #[must_use]
384    pub fn with_mask(mut self, mask: MaskRegion) -> Self {
385        self.mask_regions.push(mask);
386        self
387    }
388}
389
390/// Calculate perceptual color difference (weighted for human vision)
391///
392/// Uses weighted RGB based on human perception:
393/// - Red: 0.299
394/// - Green: 0.587
395/// - Blue: 0.114
396#[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    // Use weighted RGB based on human perception
402    // Red: 0.299, Green: 0.587, Blue: 0.114
403    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        // White vs white should be 0
467        assert!((perceptual_diff(white, white) - 0.0).abs() < f64::EPSILON);
468
469        // White vs black should be non-zero
470        let wb_diff = perceptual_diff(white, black);
471        assert!(wb_diff > 0.0);
472
473        // Red vs black should be less than white vs black (red weighted at 0.299)
474        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        // Create a simple 2x2 red image
506        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        // Create two different 2x2 images
528        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]); // Red
533        }
534        for pixel in img2.pixels_mut() {
535            *pixel = Rgba([0, 255, 0, 255]); // Green
536        }
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)); // 1% threshold
573        assert!(!result.within_threshold(0.0005)); // 0.05% threshold should fail
574    }
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        // Create two images with small differences
615        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            // Make one pixel different
621            if i == 0 {
622                img2.put_pixel(0, 0, Rgba([105, 105, 105, 255])); // Small diff
623            } 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        // With default color threshold of 10, this should pass
642        let tester = VisualRegressionTester::default();
643        let result = tester.compare_images(&buffer1, &buffer2).unwrap();
644
645        assert!(result.matches); // Small diff within color threshold
646    }
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        // Create a simple image
655        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); // Clean up from previous runs
671
672        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        // Create a simple image
678        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        // First call should create baseline
689        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        // Second call should compare against baseline
696        let result2 = tester
697            .compare_against_baseline("update_test", &buffer)
698            .unwrap();
699        assert!(result2.matches);
700
701        // Cleanup
702        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) // Very strict threshold
718            .with_color_threshold(0); // No color tolerance
719        config.diff_dir = diff_dir.to_string_lossy().to_string();
720        let tester = VisualRegressionTester::new(config);
721
722        // Create baseline image (red)
723        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        // Create different image (green)
735        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        // Compare - should fail and save diff
746        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        // Cleanup
753        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        // Create two very different images - should generate diff image
760        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]); // Black
765        }
766        for pixel in img2.pixels_mut() {
767            *pixel = Rgba([255, 255, 255, 255]); // White
768        }
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        // Create images with measurable color difference
795        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]); // +100 difference in red
803        }
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]; // Not a valid image
831
832        let result = tester.compare_images(&invalid_data, &invalid_data);
833        assert!(result.is_err());
834    }
835
836    // ============================================================================
837    // QA CHECKLIST SECTION 4: Visual Regression Falsification Tests
838    // Per docs/qa/100-point-qa-checklist-jugar-probar.md
839    // ============================================================================
840
841    /// Test #70: HDR content handling - dynamic range normalization
842    #[test]
843    #[allow(clippy::cast_possible_truncation, clippy::items_after_statements)]
844    fn test_hdr_content_handling() {
845        // HDR images use extended color values (>255)
846        // Our comparison normalizes to 8-bit for consistent comparison
847        let hdr_pixel_value: u16 = 512; // Extended range
848        let sdr_normalized = hdr_pixel_value.min(255) as u8; // Clamped to SDR
849
850        assert_eq!(sdr_normalized, 255, "HDR values clamped to SDR range");
851
852        // Verify tone mapping simulation
853        #[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 color depth handling
871    #[test]
872    #[allow(clippy::cast_possible_truncation)]
873    fn test_color_depth_normalization() {
874        // 10-bit to 8-bit conversion
875        let ten_bit_value: u16 = 1023; // Max 10-bit
876        let eight_bit_value = (ten_bit_value >> 2) as u8; // Convert to 8-bit
877
878        assert_eq!(eight_bit_value, 255, "10-bit normalized to 8-bit");
879
880        // 16-bit to 8-bit conversion
881        let sixteen_bit_value: u16 = 65535; // Max 16-bit
882        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    // =========================================================================
888    // Hâ‚€ EXTREME TDD: Visual Regression Tests (Spec G.4 P0)
889    // =========================================================================
890
891    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)); // 2% threshold
998        }
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)); // 5% threshold
1012        }
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])); // +10 diff
1145            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])); // +50 diff
1158            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) // 50% of pixels can differ
1167                    .with_color_threshold(0),
1168            );
1169            // 10x10 = 100 pixels, allow up to 50 to differ
1170            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); // 30% differ, threshold is 50%
1192        }
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])); // Only red differs
1241            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}