Skip to main content

presentar_test/
snapshot.rs

1//! Visual regression testing via snapshot comparison.
2//!
3//! Pure Rust implementation - no external dependencies.
4
5use presentar_core::draw::DrawCommand;
6use presentar_core::Color;
7use std::collections::hash_map::DefaultHasher;
8use std::hash::{Hash, Hasher};
9use std::path::{Path, PathBuf};
10
11/// Image data for snapshot comparison.
12#[derive(Debug, Clone)]
13pub struct Image {
14    /// Width in pixels
15    pub width: u32,
16    /// Height in pixels
17    pub height: u32,
18    /// RGBA pixel data (4 bytes per pixel)
19    pub data: Vec<u8>,
20}
21
22impl Image {
23    /// Create a new image with the given dimensions.
24    #[must_use]
25    pub fn new(width: u32, height: u32) -> Self {
26        let size = (width as usize) * (height as usize) * 4;
27        Self {
28            width,
29            height,
30            data: vec![0; size],
31        }
32    }
33
34    /// Create an image filled with a single color.
35    #[must_use]
36    pub fn filled(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> Self {
37        let size = (width as usize) * (height as usize) * 4;
38        let mut data = Vec::with_capacity(size);
39        for _ in 0..(width * height) {
40            data.extend_from_slice(&[r, g, b, a]);
41        }
42        Self {
43            width,
44            height,
45            data,
46        }
47    }
48
49    /// Get raw bytes.
50    #[must_use]
51    pub fn as_bytes(&self) -> &[u8] {
52        &self.data
53    }
54
55    /// Get pixel at position.
56    #[must_use]
57    pub fn get_pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> {
58        if x >= self.width || y >= self.height {
59            return None;
60        }
61        let idx = ((y * self.width + x) * 4) as usize;
62        Some([
63            self.data[idx],
64            self.data[idx + 1],
65            self.data[idx + 2],
66            self.data[idx + 3],
67        ])
68    }
69
70    /// Set pixel at position.
71    pub fn set_pixel(&mut self, x: u32, y: u32, rgba: [u8; 4]) {
72        if x < self.width && y < self.height {
73            let idx = ((y * self.width + x) * 4) as usize;
74            self.data[idx..idx + 4].copy_from_slice(&rgba);
75        }
76    }
77
78    /// Fill a rectangle with a color (software rendering).
79    #[allow(clippy::cast_possible_wrap)]
80    pub fn fill_rect(&mut self, x: i32, y: i32, width: u32, height: u32, color: &Color) {
81        let rgba = [
82            (color.r * 255.0) as u8,
83            (color.g * 255.0) as u8,
84            (color.b * 255.0) as u8,
85            (color.a * 255.0) as u8,
86        ];
87
88        let x_start = x.max(0) as u32;
89        let y_start = y.max(0) as u32;
90        let x_end = ((x + width as i32) as u32).min(self.width);
91        let y_end = ((y + height as i32) as u32).min(self.height);
92
93        for py in y_start..y_end {
94            for px in x_start..x_end {
95                self.blend_pixel(px, py, rgba);
96            }
97        }
98    }
99
100    /// Blend a pixel with alpha compositing.
101    #[allow(clippy::cast_lossless, clippy::needless_range_loop)]
102    fn blend_pixel(&mut self, x: u32, y: u32, src: [u8; 4]) {
103        if x >= self.width || y >= self.height {
104            return;
105        }
106        let idx = ((y * self.width + x) * 4) as usize;
107
108        let src_a = src[3] as f32 / 255.0;
109        if src_a >= 0.999 {
110            self.data[idx..idx + 4].copy_from_slice(&src);
111            return;
112        }
113
114        let dst_a = self.data[idx + 3] as f32 / 255.0;
115        let out_a = src_a + dst_a * (1.0 - src_a);
116
117        if out_a > 0.0 {
118            for i in 0..3 {
119                let src_c = src[i] as f32 / 255.0;
120                let dst_c = self.data[idx + i] as f32 / 255.0;
121                let out_c = (src_c * src_a + dst_c * dst_a * (1.0 - src_a)) / out_a;
122                self.data[idx + i] = (out_c * 255.0) as u8;
123            }
124            self.data[idx + 3] = (out_a * 255.0) as u8;
125        }
126    }
127
128    /// Fill a circle with a color (software rendering).
129    #[allow(clippy::cast_possible_wrap)]
130    pub fn fill_circle(&mut self, cx: i32, cy: i32, radius: u32, color: &Color) {
131        let rgba = [
132            (color.r * 255.0) as u8,
133            (color.g * 255.0) as u8,
134            (color.b * 255.0) as u8,
135            (color.a * 255.0) as u8,
136        ];
137
138        let r = radius as i32;
139        let r_sq = (r * r) as f32;
140
141        for dy in -r..=r {
142            for dx in -r..=r {
143                let dist_sq = (dx * dx + dy * dy) as f32;
144                if dist_sq <= r_sq {
145                    let px = cx + dx;
146                    let py = cy + dy;
147                    if px >= 0 && py >= 0 {
148                        self.blend_pixel(px as u32, py as u32, rgba);
149                    }
150                }
151            }
152        }
153    }
154
155    /// Render draw commands to this image (software renderer).
156    pub fn render(&mut self, commands: &[DrawCommand]) {
157        for cmd in commands {
158            self.render_command(cmd);
159        }
160    }
161
162    fn render_command(&mut self, cmd: &DrawCommand) {
163        match cmd {
164            DrawCommand::Rect { bounds, style, .. } => {
165                if let Some(fill) = style.fill {
166                    self.fill_rect(
167                        bounds.x as i32,
168                        bounds.y as i32,
169                        bounds.width as u32,
170                        bounds.height as u32,
171                        &fill,
172                    );
173                }
174            }
175            DrawCommand::Circle {
176                center,
177                radius,
178                style,
179            } => {
180                if let Some(fill) = style.fill {
181                    self.fill_circle(center.x as i32, center.y as i32, *radius as u32, &fill);
182                }
183            }
184            DrawCommand::Group { children, .. } => {
185                self.render(children);
186            }
187            _ => {}
188        }
189    }
190
191    /// Compute a hash of the image data.
192    #[must_use]
193    pub fn hash(&self) -> u64 {
194        let mut hasher = DefaultHasher::new();
195        self.width.hash(&mut hasher);
196        self.height.hash(&mut hasher);
197        self.data.hash(&mut hasher);
198        hasher.finish()
199    }
200
201    /// Extract a sub-region of the image.
202    #[must_use]
203    pub fn region(&self, x: u32, y: u32, width: u32, height: u32) -> Image {
204        let mut result = Image::new(width, height);
205
206        for dy in 0..height {
207            for dx in 0..width {
208                if let Some(pixel) = self.get_pixel(x + dx, y + dy) {
209                    result.set_pixel(dx, dy, pixel);
210                }
211            }
212        }
213
214        result
215    }
216
217    /// Scale image to new dimensions (nearest neighbor).
218    #[must_use]
219    pub fn scale(&self, new_width: u32, new_height: u32) -> Image {
220        let mut result = Image::new(new_width, new_height);
221
222        if self.width == 0 || self.height == 0 {
223            return result;
224        }
225
226        for y in 0..new_height {
227            for x in 0..new_width {
228                let src_x = (x as f32 * self.width as f32 / new_width as f32) as u32;
229                let src_y = (y as f32 * self.height as f32 / new_height as f32) as u32;
230                if let Some(pixel) = self.get_pixel(src_x, src_y) {
231                    result.set_pixel(x, y, pixel);
232                }
233            }
234        }
235
236        result
237    }
238
239    /// Count pixels matching a specific color (with tolerance).
240    #[must_use]
241    pub fn count_color(&self, target: [u8; 4], tolerance: u8) -> usize {
242        let mut count = 0;
243
244        for y in 0..self.height {
245            for x in 0..self.width {
246                if let Some(pixel) = self.get_pixel(x, y) {
247                    let matches = (0..4).all(|i| {
248                        let diff = (pixel[i] as i32 - target[i] as i32).unsigned_abs() as u8;
249                        diff <= tolerance
250                    });
251                    if matches {
252                        count += 1;
253                    }
254                }
255            }
256        }
257
258        count
259    }
260
261    /// Calculate histogram for each channel.
262    #[must_use]
263    pub fn histogram(&self) -> [[u32; 256]; 4] {
264        let mut hist = [[0u32; 256]; 4];
265
266        for chunk in self.data.chunks_exact(4) {
267            for (i, &val) in chunk.iter().enumerate() {
268                hist[i][val as usize] += 1;
269            }
270        }
271
272        hist
273    }
274
275    /// Calculate mean color.
276    #[must_use]
277    pub fn mean_color(&self) -> [f32; 4] {
278        let pixel_count = (self.width * self.height) as f64;
279        if pixel_count == 0.0 {
280            return [0.0; 4];
281        }
282
283        let mut sums = [0.0f64; 4];
284
285        for chunk in self.data.chunks_exact(4) {
286            for (i, &val) in chunk.iter().enumerate() {
287                sums[i] += f64::from(val);
288            }
289        }
290
291        [
292            (sums[0] / pixel_count) as f32,
293            (sums[1] / pixel_count) as f32,
294            (sums[2] / pixel_count) as f32,
295            (sums[3] / pixel_count) as f32,
296        ]
297    }
298
299    /// Draw a line using Bresenham's algorithm.
300    #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
301    pub fn draw_line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: &Color) {
302        let rgba = [
303            (color.r * 255.0) as u8,
304            (color.g * 255.0) as u8,
305            (color.b * 255.0) as u8,
306            (color.a * 255.0) as u8,
307        ];
308
309        let dx = (x1 - x0).abs();
310        let dy = -(y1 - y0).abs();
311        let sx = if x0 < x1 { 1 } else { -1 };
312        let sy = if y0 < y1 { 1 } else { -1 };
313        let mut err = dx + dy;
314
315        let mut x = x0;
316        let mut y = y0;
317
318        loop {
319            if x >= 0 && y >= 0 {
320                self.blend_pixel(x as u32, y as u32, rgba);
321            }
322
323            if x == x1 && y == y1 {
324                break;
325            }
326
327            let e2 = 2 * err;
328            if e2 >= dy {
329                if x == x1 {
330                    break;
331                }
332                err += dy;
333                x += sx;
334            }
335            if e2 <= dx {
336                if y == y1 {
337                    break;
338                }
339                err += dx;
340                y += sy;
341            }
342        }
343    }
344
345    /// Draw a stroked rectangle.
346    #[allow(clippy::cast_possible_wrap)]
347    pub fn stroke_rect(&mut self, x: i32, y: i32, width: u32, height: u32, color: &Color) {
348        let x2 = x + width as i32 - 1;
349        let y2 = y + height as i32 - 1;
350
351        self.draw_line(x, y, x2, y, color);
352        self.draw_line(x2, y, x2, y2, color);
353        self.draw_line(x2, y2, x, y2, color);
354        self.draw_line(x, y2, x, y, color);
355    }
356}
357
358/// Result of a snapshot comparison.
359#[derive(Debug, Clone)]
360pub struct ComparisonResult {
361    /// Byte-level difference ratio (0.0 to 1.0)
362    pub byte_diff: f64,
363    /// Perceptual difference (0.0 to 1.0)
364    pub perceptual_diff: f64,
365    /// Structural similarity index (0.0 to 1.0, 1.0 = identical)
366    pub ssim: f64,
367    /// Whether dimensions match
368    pub same_dimensions: bool,
369    /// Number of changed pixels
370    pub changed_pixels: u64,
371    /// Total pixels
372    pub total_pixels: u64,
373}
374
375impl ComparisonResult {
376    /// Check if images are considered equivalent given a threshold.
377    #[must_use]
378    pub fn is_match(&self, threshold: f64) -> bool {
379        self.same_dimensions && self.byte_diff <= threshold
380    }
381
382    /// Get the percentage of changed pixels.
383    #[must_use]
384    pub fn changed_percentage(&self) -> f64 {
385        if self.total_pixels == 0 {
386            return 0.0;
387        }
388        self.changed_pixels as f64 / self.total_pixels as f64 * 100.0
389    }
390}
391
392/// Snapshot comparison utilities.
393pub struct Snapshot;
394
395impl Snapshot {
396    /// Compare an actual image against a baseline snapshot.
397    ///
398    /// # Panics
399    ///
400    /// Panics if the difference exceeds the threshold.
401    pub fn assert_match(name: &str, actual: &Image, threshold: f64) {
402        let baseline_path = Self::baseline_path(name);
403
404        if let Some(baseline) = Self::load_baseline(&baseline_path) {
405            let diff_ratio = Self::diff(&baseline, actual);
406
407            if diff_ratio > threshold {
408                // Save actual and diff for debugging
409                let actual_path = Self::actual_path(name);
410                let diff_path = Self::diff_path(name);
411
412                Self::save_image(&actual_path, actual);
413                Self::save_diff(&diff_path, &baseline, actual);
414
415                panic!(
416                    "Visual regression '{}': {:.2}% diff (threshold: {:.2}%)\n\
417                     Baseline: {}\n\
418                     Actual: {}\n\
419                     Diff: {}",
420                    name,
421                    diff_ratio * 100.0,
422                    threshold * 100.0,
423                    baseline_path.display(),
424                    actual_path.display(),
425                    diff_path.display()
426                );
427            }
428        } else if std::env::var("SNAPSHOT_UPDATE").is_ok() {
429            // Create new baseline
430            Self::save_image(&baseline_path, actual);
431            println!("Created new baseline: {}", baseline_path.display());
432        } else {
433            panic!(
434                "No baseline found for '{}'. Run with SNAPSHOT_UPDATE=1 to create.\n\
435                 Expected path: {}",
436                name,
437                baseline_path.display()
438            );
439        }
440    }
441
442    /// Calculate difference ratio between two images (byte-level).
443    #[must_use]
444    pub fn diff(a: &Image, b: &Image) -> f64 {
445        if a.width != b.width || a.height != b.height {
446            return 1.0; // Completely different if sizes don't match
447        }
448
449        let mut diff_count = 0u64;
450        let total = a.data.len() as u64;
451
452        for (a_byte, b_byte) in a.data.iter().zip(b.data.iter()) {
453            if a_byte != b_byte {
454                diff_count += 1;
455            }
456        }
457
458        diff_count as f64 / total as f64
459    }
460
461    /// Calculate perceptual difference (color distance per pixel).
462    /// Returns mean squared error normalized to 0.0-1.0.
463    #[must_use]
464    pub fn perceptual_diff(a: &Image, b: &Image) -> f64 {
465        if a.width != b.width || a.height != b.height {
466            return 1.0;
467        }
468
469        let pixel_count = f64::from(a.width * a.height);
470        if pixel_count == 0.0 {
471            return 0.0;
472        }
473
474        let mut total_error = 0.0;
475
476        for i in 0..(a.data.len() / 4) {
477            let idx = i * 4;
478            // Calculate color distance (squared Euclidean in RGB space)
479            for j in 0..3 {
480                let diff = f64::from(a.data[idx + j]) - f64::from(b.data[idx + j]);
481                total_error += diff * diff;
482            }
483        }
484
485        // Normalize: max possible error is 255^2 * 3 * pixel_count
486        let max_error = 255.0 * 255.0 * 3.0 * pixel_count;
487        total_error / max_error
488    }
489
490    /// Generate a difference image highlighting changed pixels.
491    #[must_use]
492    pub fn generate_diff_image(a: &Image, b: &Image) -> Image {
493        let width = a.width.max(b.width);
494        let height = a.height.max(b.height);
495        let mut diff = Image::new(width, height);
496
497        for y in 0..height {
498            for x in 0..width {
499                let pixel_a = a.get_pixel(x, y).unwrap_or([0, 0, 0, 0]);
500                let pixel_b = b.get_pixel(x, y).unwrap_or([0, 0, 0, 0]);
501
502                let color_diff: i32 = (0..3)
503                    .map(|i| (i32::from(pixel_a[i]) - i32::from(pixel_b[i])).abs())
504                    .sum();
505
506                if color_diff == 0 {
507                    // Same pixel - show dimmed
508                    diff.set_pixel(x, y, [pixel_a[0] / 4, pixel_a[1] / 4, pixel_a[2] / 4, 255]);
509                } else {
510                    // Different - highlight in red
511                    let intensity = (color_diff.min(255 * 3) / 3) as u8;
512                    diff.set_pixel(x, y, [255, intensity, intensity, 255]);
513                }
514            }
515        }
516
517        diff
518    }
519
520    /// Perform a comprehensive comparison between two images.
521    #[must_use]
522    pub fn compare(a: &Image, b: &Image) -> ComparisonResult {
523        let same_dimensions = a.width == b.width && a.height == b.height;
524        let total_pixels = u64::from(a.width) * u64::from(a.height);
525
526        if !same_dimensions {
527            return ComparisonResult {
528                byte_diff: 1.0,
529                perceptual_diff: 1.0,
530                ssim: 0.0,
531                same_dimensions: false,
532                changed_pixels: total_pixels,
533                total_pixels,
534            };
535        }
536
537        let byte_diff = Self::diff(a, b);
538        let perceptual_diff = Self::perceptual_diff(a, b);
539        let ssim = Self::ssim(a, b);
540        let changed_pixels = Self::count_changed_pixels(a, b);
541
542        ComparisonResult {
543            byte_diff,
544            perceptual_diff,
545            ssim,
546            same_dimensions: true,
547            changed_pixels,
548            total_pixels,
549        }
550    }
551
552    /// Count the number of pixels that differ between two images.
553    #[must_use]
554    pub fn count_changed_pixels(a: &Image, b: &Image) -> u64 {
555        if a.width != b.width || a.height != b.height {
556            return u64::from(a.width.max(b.width)) * u64::from(a.height.max(b.height));
557        }
558
559        let mut count = 0u64;
560        for i in 0..(a.data.len() / 4) {
561            let idx = i * 4;
562            let diff = (0..4).any(|j| a.data[idx + j] != b.data[idx + j]);
563            if diff {
564                count += 1;
565            }
566        }
567        count
568    }
569
570    /// Calculate Structural Similarity Index (SSIM).
571    ///
572    /// Simplified implementation of SSIM that considers luminance similarity.
573    /// Returns 1.0 for identical images, 0.0 for completely different.
574    #[must_use]
575    pub fn ssim(a: &Image, b: &Image) -> f64 {
576        if a.width != b.width || a.height != b.height {
577            return 0.0;
578        }
579
580        let pixel_count = (a.width * a.height) as usize;
581        if pixel_count == 0 {
582            return 1.0;
583        }
584
585        // Calculate luminance for each image
586        let mut lum_a = Vec::with_capacity(pixel_count);
587        let mut lum_b = Vec::with_capacity(pixel_count);
588
589        for i in 0..pixel_count {
590            let idx = i * 4;
591            // Standard luminance calculation
592            let la = 0.299 * f64::from(a.data[idx])
593                + 0.587 * f64::from(a.data[idx + 1])
594                + 0.114 * f64::from(a.data[idx + 2]);
595            let lb = 0.299 * f64::from(b.data[idx])
596                + 0.587 * f64::from(b.data[idx + 1])
597                + 0.114 * f64::from(b.data[idx + 2]);
598            lum_a.push(la);
599            lum_b.push(lb);
600        }
601
602        // Calculate means
603        let mean_a: f64 = lum_a.iter().sum::<f64>() / pixel_count as f64;
604        let mean_b: f64 = lum_b.iter().sum::<f64>() / pixel_count as f64;
605
606        // Calculate variances and covariance
607        let mut var_a = 0.0;
608        let mut var_b = 0.0;
609        let mut covar = 0.0;
610
611        for i in 0..pixel_count {
612            let da = lum_a[i] - mean_a;
613            let db = lum_b[i] - mean_b;
614            var_a += da * da;
615            var_b += db * db;
616            covar += da * db;
617        }
618
619        var_a /= pixel_count as f64;
620        var_b /= pixel_count as f64;
621        covar /= pixel_count as f64;
622
623        // SSIM constants
624        const C1: f64 = 6.5025; // (0.01 * 255)^2
625        const C2: f64 = 58.5225; // (0.03 * 255)^2
626
627        // Simplified SSIM formula
628        let numerator = (2.0 * mean_a * mean_b + C1) * (2.0 * covar + C2);
629        let denominator = (mean_a * mean_a + mean_b * mean_b + C1) * (var_a + var_b + C2);
630
631        numerator / denominator
632    }
633
634    /// Compare a region of two images.
635    #[must_use]
636    pub fn compare_region(
637        a: &Image,
638        b: &Image,
639        x: u32,
640        y: u32,
641        width: u32,
642        height: u32,
643    ) -> ComparisonResult {
644        let region_a = a.region(x, y, width, height);
645        let region_b = b.region(x, y, width, height);
646        Self::compare(&region_a, &region_b)
647    }
648
649    /// Assert that a region matches within threshold.
650    ///
651    /// # Panics
652    ///
653    /// Panics if the region difference exceeds the threshold.
654    pub fn assert_region_match(
655        name: &str,
656        actual: &Image,
657        baseline: &Image,
658        x: u32,
659        y: u32,
660        width: u32,
661        height: u32,
662        threshold: f64,
663    ) {
664        let result = Self::compare_region(actual, baseline, x, y, width, height);
665        if !result.is_match(threshold) {
666            panic!(
667                "Region mismatch in '{}' at ({}, {}) {}x{}: {:.2}% diff (threshold: {:.2}%)",
668                name,
669                x,
670                y,
671                width,
672                height,
673                result.byte_diff * 100.0,
674                threshold * 100.0
675            );
676        }
677    }
678
679    fn baseline_path(name: &str) -> PathBuf {
680        PathBuf::from(format!("tests/snapshots/{name}.png"))
681    }
682
683    fn actual_path(name: &str) -> PathBuf {
684        PathBuf::from(format!("tests/snapshots/{name}.actual.png"))
685    }
686
687    fn diff_path(name: &str) -> PathBuf {
688        PathBuf::from(format!("tests/snapshots/{name}.diff.png"))
689    }
690
691    fn load_baseline(path: &Path) -> Option<Image> {
692        // Simplified - would use a pure Rust PNG decoder
693        if path.exists() {
694            // Return placeholder
695            Some(Image::new(100, 100))
696        } else {
697            None
698        }
699    }
700
701    const fn save_image(_path: &Path, _image: &Image) {
702        // Would use a pure Rust PNG encoder
703        // Placeholder implementation
704    }
705
706    const fn save_diff(_path: &Path, _baseline: &Image, _actual: &Image) {
707        // Would generate a visual diff image
708        // Placeholder implementation
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    #[test]
717    fn test_image_new() {
718        let img = Image::new(100, 100);
719        assert_eq!(img.width, 100);
720        assert_eq!(img.height, 100);
721        assert_eq!(img.data.len(), 100 * 100 * 4);
722    }
723
724    #[test]
725    fn test_image_filled() {
726        let img = Image::filled(10, 10, 255, 0, 0, 255);
727        assert_eq!(img.get_pixel(0, 0), Some([255, 0, 0, 255]));
728        assert_eq!(img.get_pixel(5, 5), Some([255, 0, 0, 255]));
729    }
730
731    #[test]
732    fn test_image_get_set_pixel() {
733        let mut img = Image::new(10, 10);
734        img.set_pixel(5, 5, [255, 128, 64, 255]);
735        assert_eq!(img.get_pixel(5, 5), Some([255, 128, 64, 255]));
736    }
737
738    #[test]
739    fn test_image_get_pixel_out_of_bounds() {
740        let img = Image::new(10, 10);
741        assert_eq!(img.get_pixel(100, 100), None);
742    }
743
744    #[test]
745    fn test_diff_identical() {
746        let a = Image::filled(10, 10, 255, 0, 0, 255);
747        let b = Image::filled(10, 10, 255, 0, 0, 255);
748        assert_eq!(Snapshot::diff(&a, &b), 0.0);
749    }
750
751    #[test]
752    fn test_diff_completely_different() {
753        let a = Image::filled(10, 10, 255, 0, 0, 255);
754        let b = Image::filled(10, 10, 0, 255, 0, 255);
755        let diff = Snapshot::diff(&a, &b);
756        assert!(diff > 0.0);
757    }
758
759    #[test]
760    fn test_diff_different_sizes() {
761        let a = Image::new(10, 10);
762        let b = Image::new(20, 20);
763        assert_eq!(Snapshot::diff(&a, &b), 1.0);
764    }
765
766    #[test]
767    fn test_diff_partial() {
768        let a = Image::filled(10, 10, 255, 0, 0, 255);
769        let mut b = Image::filled(10, 10, 255, 0, 0, 255);
770
771        // Change one pixel from [255,0,0,255] to [0,0,0,255]
772        // Only R channel changes (255 -> 0)
773        b.set_pixel(0, 0, [0, 0, 0, 255]);
774
775        let diff = Snapshot::diff(&a, &b);
776        // 1 byte changed out of 400 total bytes = 0.0025
777        assert!((diff - 0.0025).abs() < 0.001);
778    }
779
780    #[test]
781    fn test_fill_rect() {
782        let mut img = Image::new(100, 100);
783        img.fill_rect(10, 10, 20, 20, &Color::RED);
784
785        // Inside rect should be red
786        assert_eq!(img.get_pixel(15, 15), Some([255, 0, 0, 255]));
787        // Outside rect should be black/transparent
788        assert_eq!(img.get_pixel(0, 0), Some([0, 0, 0, 0]));
789    }
790
791    #[test]
792    fn test_fill_rect_clipping() {
793        let mut img = Image::new(50, 50);
794        // Rect extends beyond bounds
795        img.fill_rect(40, 40, 20, 20, &Color::BLUE);
796
797        // Inside visible portion should be blue
798        assert_eq!(img.get_pixel(45, 45), Some([0, 0, 255, 255]));
799        // Edge should also be blue
800        assert_eq!(img.get_pixel(49, 49), Some([0, 0, 255, 255]));
801    }
802
803    #[test]
804    fn test_fill_circle() {
805        let mut img = Image::new(100, 100);
806        img.fill_circle(50, 50, 10, &Color::GREEN);
807
808        // Center should be green
809        assert_eq!(img.get_pixel(50, 50), Some([0, 255, 0, 255]));
810        // Just outside radius should be transparent
811        assert_eq!(img.get_pixel(50, 65), Some([0, 0, 0, 0]));
812    }
813
814    #[test]
815    fn test_render_rect_command() {
816        use presentar_core::draw::DrawCommand;
817        use presentar_core::Rect;
818
819        let mut img = Image::new(100, 100);
820        let commands = vec![DrawCommand::filled_rect(
821            Rect::new(10.0, 10.0, 30.0, 30.0),
822            Color::RED,
823        )];
824        img.render(&commands);
825
826        assert_eq!(img.get_pixel(20, 20), Some([255, 0, 0, 255]));
827    }
828
829    #[test]
830    fn test_render_circle_command() {
831        use presentar_core::draw::DrawCommand;
832        use presentar_core::Point;
833
834        let mut img = Image::new(100, 100);
835        let commands = vec![DrawCommand::filled_circle(
836            Point::new(50.0, 50.0),
837            15.0,
838            Color::BLUE,
839        )];
840        img.render(&commands);
841
842        assert_eq!(img.get_pixel(50, 50), Some([0, 0, 255, 255]));
843    }
844
845    #[test]
846    fn test_perceptual_diff_identical() {
847        let a = Image::filled(10, 10, 128, 64, 32, 255);
848        let b = Image::filled(10, 10, 128, 64, 32, 255);
849        assert_eq!(Snapshot::perceptual_diff(&a, &b), 0.0);
850    }
851
852    #[test]
853    fn test_perceptual_diff_different() {
854        let a = Image::filled(10, 10, 255, 255, 255, 255);
855        let b = Image::filled(10, 10, 0, 0, 0, 255);
856        let diff = Snapshot::perceptual_diff(&a, &b);
857        // Maximum difference white vs black = 1.0
858        assert!((diff - 1.0).abs() < 0.001);
859    }
860
861    #[test]
862    fn test_perceptual_diff_partial() {
863        let a = Image::filled(10, 10, 100, 100, 100, 255);
864        let b = Image::filled(10, 10, 110, 100, 100, 255);
865        let diff = Snapshot::perceptual_diff(&a, &b);
866        // Small difference should be small value
867        assert!(diff > 0.0);
868        assert!(diff < 0.01);
869    }
870
871    #[test]
872    fn test_generate_diff_image() {
873        let a = Image::filled(10, 10, 255, 0, 0, 255);
874        let mut b = Image::filled(10, 10, 255, 0, 0, 255);
875        b.set_pixel(5, 5, [0, 255, 0, 255]);
876
877        let diff = Snapshot::generate_diff_image(&a, &b);
878
879        // Changed pixel should be highlighted (red channel = 255)
880        let pixel = diff.get_pixel(5, 5).expect("pixel exists");
881        assert_eq!(pixel[0], 255); // Red highlight
882
883        // Unchanged pixels should be dimmed
884        let unchanged = diff.get_pixel(0, 0).expect("pixel exists");
885        assert!(unchanged[0] < 100); // Dimmed red
886    }
887
888    #[test]
889    fn test_alpha_blending() {
890        let mut img = Image::filled(10, 10, 255, 0, 0, 255); // Red background
891        img.fill_rect(0, 0, 10, 10, &Color::new(0.0, 0.0, 1.0, 0.5)); // 50% blue overlay
892
893        let pixel = img.get_pixel(5, 5).expect("pixel exists");
894        // Should be a blend of red and blue
895        assert!(pixel[0] > 100); // Still has red
896        assert!(pixel[2] > 100); // Has blue
897    }
898
899    // ===== New functionality tests =====
900
901    #[test]
902    fn test_image_hash() {
903        let a = Image::filled(10, 10, 255, 0, 0, 255);
904        let b = Image::filled(10, 10, 255, 0, 0, 255);
905        let c = Image::filled(10, 10, 0, 255, 0, 255);
906
907        assert_eq!(a.hash(), b.hash());
908        assert_ne!(a.hash(), c.hash());
909    }
910
911    #[test]
912    fn test_image_region() {
913        let mut img = Image::new(100, 100);
914        img.fill_rect(10, 10, 20, 20, &Color::RED);
915
916        let region = img.region(10, 10, 20, 20);
917        assert_eq!(region.width, 20);
918        assert_eq!(region.height, 20);
919        assert_eq!(region.get_pixel(5, 5), Some([255, 0, 0, 255]));
920    }
921
922    #[test]
923    fn test_image_region_out_of_bounds() {
924        let img = Image::filled(10, 10, 255, 0, 0, 255);
925        let region = img.region(8, 8, 5, 5);
926
927        // Only 2x2 pixels should be valid (from 8-9 in each dimension)
928        assert_eq!(region.width, 5);
929        assert_eq!(region.height, 5);
930        // Inside original image bounds
931        assert_eq!(region.get_pixel(0, 0), Some([255, 0, 0, 255]));
932        // Outside original image bounds
933        assert_eq!(region.get_pixel(3, 3), Some([0, 0, 0, 0]));
934    }
935
936    #[test]
937    fn test_image_scale() {
938        let img = Image::filled(10, 10, 255, 0, 0, 255);
939        let scaled = img.scale(20, 20);
940
941        assert_eq!(scaled.width, 20);
942        assert_eq!(scaled.height, 20);
943        assert_eq!(scaled.get_pixel(10, 10), Some([255, 0, 0, 255]));
944    }
945
946    #[test]
947    fn test_image_scale_down() {
948        let img = Image::filled(20, 20, 255, 0, 0, 255);
949        let scaled = img.scale(10, 10);
950
951        assert_eq!(scaled.width, 10);
952        assert_eq!(scaled.height, 10);
953        assert_eq!(scaled.get_pixel(5, 5), Some([255, 0, 0, 255]));
954    }
955
956    #[test]
957    fn test_image_count_color() {
958        let img = Image::filled(10, 10, 255, 0, 0, 255);
959        let count = img.count_color([255, 0, 0, 255], 0);
960        assert_eq!(count, 100);
961
962        let count = img.count_color([255, 5, 0, 255], 10);
963        assert_eq!(count, 100);
964
965        let count = img.count_color([0, 255, 0, 255], 0);
966        assert_eq!(count, 0);
967    }
968
969    #[test]
970    fn test_image_histogram() {
971        let img = Image::filled(10, 10, 255, 128, 0, 255);
972        let hist = img.histogram();
973
974        assert_eq!(hist[0][255], 100); // R channel
975        assert_eq!(hist[1][128], 100); // G channel
976        assert_eq!(hist[2][0], 100); // B channel
977        assert_eq!(hist[3][255], 100); // A channel
978    }
979
980    #[test]
981    fn test_image_mean_color() {
982        let img = Image::filled(10, 10, 100, 100, 100, 255);
983        let mean = img.mean_color();
984
985        assert!((mean[0] - 100.0).abs() < 0.01);
986        assert!((mean[1] - 100.0).abs() < 0.01);
987        assert!((mean[2] - 100.0).abs() < 0.01);
988        assert!((mean[3] - 255.0).abs() < 0.01);
989    }
990
991    #[test]
992    fn test_image_draw_line() {
993        let mut img = Image::new(100, 100);
994        img.draw_line(0, 0, 99, 99, &Color::WHITE);
995
996        // Check start and end
997        assert_eq!(img.get_pixel(0, 0), Some([255, 255, 255, 255]));
998        assert_eq!(img.get_pixel(99, 99), Some([255, 255, 255, 255]));
999        // Check diagonal
1000        assert_eq!(img.get_pixel(50, 50), Some([255, 255, 255, 255]));
1001    }
1002
1003    #[test]
1004    fn test_image_stroke_rect() {
1005        let mut img = Image::new(100, 100);
1006        img.stroke_rect(10, 10, 20, 20, &Color::WHITE);
1007
1008        // Check corners
1009        assert_eq!(img.get_pixel(10, 10), Some([255, 255, 255, 255]));
1010        assert_eq!(img.get_pixel(29, 29), Some([255, 255, 255, 255]));
1011        // Inside should be transparent
1012        assert_eq!(img.get_pixel(15, 15), Some([0, 0, 0, 0]));
1013    }
1014
1015    #[test]
1016    fn test_comparison_result_is_match() {
1017        let result = ComparisonResult {
1018            byte_diff: 0.01,
1019            perceptual_diff: 0.01,
1020            ssim: 0.99,
1021            same_dimensions: true,
1022            changed_pixels: 1,
1023            total_pixels: 100,
1024        };
1025
1026        assert!(result.is_match(0.05));
1027        assert!(!result.is_match(0.005));
1028    }
1029
1030    #[test]
1031    fn test_comparison_result_changed_percentage() {
1032        let result = ComparisonResult {
1033            byte_diff: 0.0,
1034            perceptual_diff: 0.0,
1035            ssim: 1.0,
1036            same_dimensions: true,
1037            changed_pixels: 10,
1038            total_pixels: 100,
1039        };
1040
1041        assert!((result.changed_percentage() - 10.0).abs() < 0.01);
1042    }
1043
1044    #[test]
1045    fn test_snapshot_compare_identical() {
1046        let a = Image::filled(10, 10, 255, 0, 0, 255);
1047        let b = Image::filled(10, 10, 255, 0, 0, 255);
1048
1049        let result = Snapshot::compare(&a, &b);
1050
1051        assert!(result.is_match(0.0));
1052        assert_eq!(result.byte_diff, 0.0);
1053        assert_eq!(result.changed_pixels, 0);
1054        assert!((result.ssim - 1.0).abs() < 0.01);
1055    }
1056
1057    #[test]
1058    fn test_snapshot_compare_different_dimensions() {
1059        let a = Image::new(10, 10);
1060        let b = Image::new(20, 20);
1061
1062        let result = Snapshot::compare(&a, &b);
1063
1064        assert!(!result.same_dimensions);
1065        assert_eq!(result.byte_diff, 1.0);
1066        assert_eq!(result.ssim, 0.0);
1067    }
1068
1069    #[test]
1070    fn test_snapshot_count_changed_pixels() {
1071        let a = Image::filled(10, 10, 255, 0, 0, 255);
1072        let mut b = Image::filled(10, 10, 255, 0, 0, 255);
1073        b.set_pixel(0, 0, [0, 255, 0, 255]);
1074        b.set_pixel(1, 0, [0, 255, 0, 255]);
1075
1076        let count = Snapshot::count_changed_pixels(&a, &b);
1077        assert_eq!(count, 2);
1078    }
1079
1080    #[test]
1081    fn test_snapshot_ssim_identical() {
1082        let a = Image::filled(10, 10, 128, 128, 128, 255);
1083        let b = Image::filled(10, 10, 128, 128, 128, 255);
1084
1085        let ssim = Snapshot::ssim(&a, &b);
1086        assert!((ssim - 1.0).abs() < 0.01);
1087    }
1088
1089    #[test]
1090    fn test_snapshot_ssim_different() {
1091        let a = Image::filled(10, 10, 255, 255, 255, 255);
1092        let b = Image::filled(10, 10, 0, 0, 0, 255);
1093
1094        let ssim = Snapshot::ssim(&a, &b);
1095        assert!(ssim < 0.5); // Should be low for black vs white
1096    }
1097
1098    #[test]
1099    fn test_snapshot_ssim_different_dimensions() {
1100        let a = Image::new(10, 10);
1101        let b = Image::new(20, 20);
1102
1103        let ssim = Snapshot::ssim(&a, &b);
1104        assert_eq!(ssim, 0.0);
1105    }
1106
1107    #[test]
1108    fn test_snapshot_compare_region() {
1109        let mut a = Image::new(100, 100);
1110        a.fill_rect(10, 10, 20, 20, &Color::RED);
1111        let mut b = Image::new(100, 100);
1112        b.fill_rect(10, 10, 20, 20, &Color::RED);
1113
1114        let result = Snapshot::compare_region(&a, &b, 10, 10, 20, 20);
1115        assert!(result.is_match(0.0));
1116    }
1117
1118    #[test]
1119    fn test_snapshot_compare_region_different() {
1120        let mut a = Image::new(100, 100);
1121        a.fill_rect(10, 10, 20, 20, &Color::RED);
1122        let mut b = Image::new(100, 100);
1123        b.fill_rect(10, 10, 20, 20, &Color::BLUE);
1124
1125        let result = Snapshot::compare_region(&a, &b, 10, 10, 20, 20);
1126        assert!(!result.is_match(0.0));
1127        assert!(result.byte_diff > 0.0);
1128    }
1129}