Skip to main content

victauri_test/
visual.rs

1//! Visual regression testing — compare screenshots against baselines.
2//!
3//! Decodes PNG images, computes per-pixel RGBA diffs, and generates diff
4//! images highlighting changes. Baselines are stored in a `snapshots/`
5//! directory alongside test files.
6
7use std::path::{Path, PathBuf};
8
9use base64::Engine;
10
11use crate::error::TestError;
12
13/// A rectangular region to exclude from pixel comparison.
14#[derive(Debug, Clone)]
15pub struct MaskRegion {
16    /// X coordinate of the top-left corner.
17    pub x: u32,
18    /// Y coordinate of the top-left corner.
19    pub y: u32,
20    /// Width of the region.
21    pub width: u32,
22    /// Height of the region.
23    pub height: u32,
24}
25
26impl MaskRegion {
27    /// Create a new mask region.
28    #[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/// Named threshold presets for common use cases.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ThresholdPreset {
46    /// Pixel-perfect: tolerance 0, threshold 0%.
47    Strict,
48    /// Default: tolerance 2, threshold 0.1%.
49    Standard,
50    /// Tolerant of anti-aliasing and subpixel rendering: tolerance 5, threshold 0.5%.
51    AntiAlias,
52    /// Lenient for cross-platform use: tolerance 10, threshold 2.0%.
53    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/// Result of comparing two screenshots pixel-by-pixel.
77#[derive(Debug)]
78pub struct VisualDiff {
79    /// Percentage of pixels that matched (0.0 to 100.0).
80    pub match_percentage: f64,
81    /// Total number of pixels that differed beyond tolerance.
82    pub diff_pixel_count: usize,
83    /// Total pixels compared (excludes masked regions).
84    pub total_pixels: usize,
85    /// Number of pixels skipped by mask regions.
86    pub masked_pixels: usize,
87    /// Path to the diff image, if one was generated.
88    pub diff_image_path: Option<PathBuf>,
89}
90
91impl VisualDiff {
92    /// Returns true if the images match within the given threshold.
93    #[must_use]
94    pub fn is_match(&self, threshold_percent: f64) -> bool {
95        self.match_percentage >= (100.0 - threshold_percent)
96    }
97}
98
99/// Options for visual regression comparison.
100#[derive(Debug, Clone)]
101pub struct VisualOptions {
102    /// Directory where baseline snapshots are stored.
103    pub snapshot_dir: PathBuf,
104    /// Per-channel tolerance (0-255). Pixels differing by less than this
105    /// in all channels are considered matching.
106    pub channel_tolerance: u8,
107    /// Maximum allowed diff percentage before comparison fails.
108    pub threshold_percent: f64,
109    /// Whether to generate a diff image on mismatch.
110    pub generate_diff_image: bool,
111    /// Whether to update baselines instead of comparing.
112    pub update_baselines: bool,
113    /// Rectangular regions to exclude from comparison.
114    pub mask_regions: Vec<MaskRegion>,
115    /// Store baselines in a platform-specific subdirectory
116    /// (e.g., `tests/snapshots/windows/`). Enabled by default.
117    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    /// Apply a threshold preset, overriding `channel_tolerance` and
136    /// `threshold_percent`.
137    #[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    /// Add a mask region to exclude from comparison.
145    #[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
160/// Compares a screenshot (base64 PNG) against a stored baseline.
161///
162/// On first run (no baseline exists), saves the screenshot as the new baseline
163/// and returns a perfect match. On subsequent runs, decodes both PNGs and
164/// compares pixel-by-pixel.
165///
166/// # Errors
167///
168/// Returns [`TestError::VisualRegression`] if the diff exceeds the threshold,
169/// or [`TestError::Other`] for IO/decode failures.
170pub 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        &current,
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, &current, &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; // R
349            diff_rgba[offset + 1] = 0; // G
350            diff_rgba[offset + 2] = 0; // B
351            diff_rgba[offset + 3] = 255; // A
352        }
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        // First run saves baseline
408        let result = compare_screenshot("test_identical", &b64, &opts).unwrap();
409        assert_eq!(result.match_percentage, 100.0);
410
411        // Second run compares — should match
412        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        // Save baseline
432        compare_screenshot("test_diff", &to_base64(&baseline), &opts).unwrap();
433
434        // Compare with different image — should fail
435        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        // Diff image should exist
444        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        // Now compare without update — should match the new baseline
485        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        // Save baseline as RGBA (the standard path)
559        let baseline = make_solid_png(8, 8, 200, 100, 50);
560        // Produce the "screenshot" as RGB (triggers the RGB→RGBA branch)
561        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        // Save baseline as RGBA with equivalent gray (r=g=b=128, a=255)
582        let baseline = make_solid_png(6, 6, gray_value, gray_value, gray_value);
583        // Produce the "screenshot" as Grayscale (triggers the Grayscale→RGBA branch)
584        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        // threshold 1.0 → needs >= 99.0 → 99.5 passes
610        assert!(diff.is_match(1.0));
611        // threshold 0.5 → needs >= 99.5 → 99.5 passes (exact boundary)
612        assert!(diff.is_match(0.5));
613        // threshold 0.1 → needs >= 99.9 → 99.5 fails
614        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        // Every pixel is different — but we mask the entire image
622        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        // 4x4 image — mask the top-left 2x2 quadrant (4 pixels)
643        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        // 16 total - 4 masked = 12 compared, all 12 differ
659        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        // Ensure no OS subdir was created
715        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}