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