Skip to main content

jugar_probar/media/
png_exporter.rs

1//! PNG Screenshot Export (Feature 2)
2//!
3//! High-quality PNG screenshots with configurable compression and metadata.
4//!
5//! ## EXTREME TDD: Tests written FIRST per spec
6
7use crate::driver::Screenshot;
8use crate::result::{ProbarError, ProbarResult};
9use image::{DynamicImage, GenericImageView, Rgba, RgbaImage};
10use serde::{Deserialize, Serialize};
11use std::path::Path;
12use std::time::SystemTime;
13
14/// PNG compression level
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
16pub enum CompressionLevel {
17    /// No compression (fastest, largest files)
18    None,
19    /// Fast compression (good balance)
20    Fast,
21    /// Default compression
22    #[default]
23    Default,
24    /// Best compression (slowest, smallest files)
25    Best,
26}
27
28impl CompressionLevel {
29    /// Convert to png crate compression level
30    fn to_png_compression(self) -> png::Compression {
31        match self {
32            Self::None | Self::Fast => png::Compression::Fast,
33            Self::Default => png::Compression::Default,
34            Self::Best => png::Compression::Best,
35        }
36    }
37}
38
39/// Metadata to embed in PNG files
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41pub struct PngMetadata {
42    /// Image title
43    pub title: Option<String>,
44    /// Image description
45    pub description: Option<String>,
46    /// Timestamp when screenshot was taken
47    pub timestamp: Option<SystemTime>,
48    /// Name of the test that generated this screenshot
49    pub test_name: Option<String>,
50    /// Software that generated the image
51    pub software: Option<String>,
52}
53
54impl PngMetadata {
55    /// Create new empty metadata
56    #[must_use]
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Set the title
62    #[must_use]
63    pub fn with_title(mut self, title: impl Into<String>) -> Self {
64        self.title = Some(title.into());
65        self
66    }
67
68    /// Set the description
69    #[must_use]
70    pub fn with_description(mut self, description: impl Into<String>) -> Self {
71        self.description = Some(description.into());
72        self
73    }
74
75    /// Set the timestamp
76    #[must_use]
77    pub fn with_timestamp(mut self, timestamp: SystemTime) -> Self {
78        self.timestamp = Some(timestamp);
79        self
80    }
81
82    /// Set the test name
83    #[must_use]
84    pub fn with_test_name(mut self, name: impl Into<String>) -> Self {
85        self.test_name = Some(name.into());
86        self
87    }
88
89    /// Set the software name
90    #[must_use]
91    pub fn with_software(mut self, software: impl Into<String>) -> Self {
92        self.software = Some(software.into());
93        self
94    }
95}
96
97/// Annotation to draw on screenshots
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct Annotation {
100    /// X coordinate of the annotation
101    pub x: u32,
102    /// Y coordinate of the annotation
103    pub y: u32,
104    /// Width of the annotation (for rectangles)
105    pub width: u32,
106    /// Height of the annotation (for rectangles)
107    pub height: u32,
108    /// Color of the annotation (RGBA)
109    pub color: [u8; 4],
110    /// Type of annotation
111    pub kind: AnnotationKind,
112    /// Optional label text
113    pub label: Option<String>,
114}
115
116/// Types of annotations
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub enum AnnotationKind {
119    /// Rectangle outline
120    Rectangle,
121    /// Filled rectangle
122    FilledRectangle,
123    /// Circle/ellipse outline
124    Circle,
125    /// Arrow pointing to location
126    Arrow,
127    /// Highlight (semi-transparent overlay)
128    Highlight,
129}
130
131impl Annotation {
132    /// Create a rectangle annotation
133    #[must_use]
134    pub fn rectangle(x: u32, y: u32, width: u32, height: u32) -> Self {
135        Self {
136            x,
137            y,
138            width,
139            height,
140            color: [255, 0, 0, 255], // Red by default
141            kind: AnnotationKind::Rectangle,
142            label: None,
143        }
144    }
145
146    /// Create a highlight annotation
147    #[must_use]
148    pub fn highlight(x: u32, y: u32, width: u32, height: u32) -> Self {
149        Self {
150            x,
151            y,
152            width,
153            height,
154            color: [255, 255, 0, 128], // Semi-transparent yellow
155            kind: AnnotationKind::Highlight,
156            label: None,
157        }
158    }
159
160    /// Create a filled rectangle annotation
161    #[must_use]
162    pub fn filled_rectangle(x: u32, y: u32, width: u32, height: u32) -> Self {
163        Self {
164            x,
165            y,
166            width,
167            height,
168            color: [255, 0, 0, 255], // Red by default
169            kind: AnnotationKind::FilledRectangle,
170            label: None,
171        }
172    }
173
174    /// Create a circle annotation
175    #[must_use]
176    pub fn circle(x: u32, y: u32, diameter: u32) -> Self {
177        Self {
178            x,
179            y,
180            width: diameter,
181            height: diameter,
182            color: [0, 255, 0, 255], // Green by default
183            kind: AnnotationKind::Circle,
184            label: None,
185        }
186    }
187
188    /// Create an arrow annotation
189    #[must_use]
190    pub fn arrow(x: u32, y: u32, dx: u32, dy: u32) -> Self {
191        Self {
192            x,
193            y,
194            width: dx,
195            height: dy,
196            color: [0, 0, 255, 255], // Blue by default
197            kind: AnnotationKind::Arrow,
198            label: None,
199        }
200    }
201
202    /// Set the annotation color
203    #[must_use]
204    pub fn with_color(mut self, r: u8, g: u8, b: u8, a: u8) -> Self {
205        self.color = [r, g, b, a];
206        self
207    }
208
209    /// Set the annotation label
210    #[must_use]
211    pub fn with_label(mut self, label: impl Into<String>) -> Self {
212        self.label = Some(label.into());
213        self
214    }
215}
216
217/// PNG Exporter for high-quality screenshots
218///
219/// ## Example
220///
221/// ```ignore
222/// let exporter = PngExporter::new()
223///     .with_compression(CompressionLevel::Best)
224///     .with_metadata(PngMetadata::new().with_title("Login Test"));
225///
226/// let png_data = exporter.export(&screenshot)?;
227/// exporter.save(&screenshot, Path::new("screenshot.png"))?;
228/// ```
229#[derive(Debug, Clone)]
230pub struct PngExporter {
231    compression: CompressionLevel,
232    metadata: PngMetadata,
233}
234
235impl Default for PngExporter {
236    fn default() -> Self {
237        Self::new()
238    }
239}
240
241impl PngExporter {
242    /// Create a new PNG exporter with default settings
243    #[must_use]
244    pub fn new() -> Self {
245        Self {
246            compression: CompressionLevel::Default,
247            metadata: PngMetadata::new().with_software("Probar".to_string()),
248        }
249    }
250
251    /// Set the compression level
252    #[must_use]
253    pub fn with_compression(mut self, compression: CompressionLevel) -> Self {
254        self.compression = compression;
255        self
256    }
257
258    /// Set the metadata
259    #[must_use]
260    pub fn with_metadata(mut self, metadata: PngMetadata) -> Self {
261        self.metadata = metadata;
262        self
263    }
264
265    /// Get the current compression level
266    #[must_use]
267    pub fn compression(&self) -> CompressionLevel {
268        self.compression
269    }
270
271    /// Get the current metadata
272    #[must_use]
273    pub fn metadata(&self) -> &PngMetadata {
274        &self.metadata
275    }
276
277    /// Export a screenshot to PNG data
278    ///
279    /// # Errors
280    ///
281    /// Returns error if encoding fails
282    pub fn export(&self, screenshot: &Screenshot) -> ProbarResult<Vec<u8>> {
283        // Decode the screenshot if it's already PNG
284        let img = image::load_from_memory(&screenshot.data).map_err(|e| {
285            ProbarError::ImageProcessing {
286                message: format!("Failed to decode screenshot: {e}"),
287            }
288        })?;
289
290        self.encode_png(&img)
291    }
292
293    /// Export a screenshot with annotations
294    ///
295    /// # Errors
296    ///
297    /// Returns error if encoding fails
298    pub fn export_with_annotations(
299        &self,
300        screenshot: &Screenshot,
301        annotations: &[Annotation],
302    ) -> ProbarResult<Vec<u8>> {
303        // Decode the screenshot
304        let img = image::load_from_memory(&screenshot.data).map_err(|e| {
305            ProbarError::ImageProcessing {
306                message: format!("Failed to decode screenshot: {e}"),
307            }
308        })?;
309
310        let mut rgba = img.to_rgba8();
311
312        // Draw annotations
313        for annotation in annotations {
314            Self::draw_annotation(&mut rgba, annotation);
315        }
316
317        self.encode_png(&DynamicImage::ImageRgba8(rgba))
318    }
319
320    /// Save a screenshot to a file
321    ///
322    /// # Errors
323    ///
324    /// Returns error if encoding or file write fails
325    pub fn save(&self, screenshot: &Screenshot, path: &Path) -> ProbarResult<()> {
326        let data = self.export(screenshot)?;
327        std::fs::write(path, data)?;
328        Ok(())
329    }
330
331    /// Save a screenshot with annotations to a file
332    ///
333    /// # Errors
334    ///
335    /// Returns error if encoding or file write fails
336    pub fn save_with_annotations(
337        &self,
338        screenshot: &Screenshot,
339        annotations: &[Annotation],
340        path: &Path,
341    ) -> ProbarResult<()> {
342        let data = self.export_with_annotations(screenshot, annotations)?;
343        std::fs::write(path, data)?;
344        Ok(())
345    }
346
347    /// Encode an image to PNG with the configured settings
348    fn encode_png(&self, img: &DynamicImage) -> ProbarResult<Vec<u8>> {
349        let (width, height) = img.dimensions();
350        let rgba = img.to_rgba8();
351
352        let mut output = Vec::new();
353
354        {
355            let mut encoder = png::Encoder::new(&mut output, width, height);
356            encoder.set_color(png::ColorType::Rgba);
357            encoder.set_depth(png::BitDepth::Eight);
358            encoder.set_compression(self.compression.to_png_compression());
359
360            let mut writer = encoder
361                .write_header()
362                .map_err(|e| ProbarError::ImageProcessing {
363                    message: format!("Failed to write PNG header: {e}"),
364                })?;
365
366            writer
367                .write_image_data(&rgba)
368                .map_err(|e| ProbarError::ImageProcessing {
369                    message: format!("Failed to write PNG data: {e}"),
370                })?;
371        }
372
373        Ok(output)
374    }
375
376    /// Draw an annotation on an image
377    fn draw_annotation(img: &mut RgbaImage, annotation: &Annotation) {
378        let color = Rgba(annotation.color);
379
380        match annotation.kind {
381            AnnotationKind::Rectangle => {
382                Self::draw_rectangle_outline(img, annotation, color);
383            }
384            AnnotationKind::FilledRectangle => {
385                Self::draw_filled_rectangle(img, annotation, color);
386            }
387            AnnotationKind::Highlight => {
388                Self::draw_highlight(img, annotation);
389            }
390            AnnotationKind::Circle | AnnotationKind::Arrow => {
391                // Simplified: draw as rectangle for now
392                Self::draw_rectangle_outline(img, annotation, color);
393            }
394        }
395    }
396
397    /// Draw a rectangle outline
398    fn draw_rectangle_outline(img: &mut RgbaImage, ann: &Annotation, color: Rgba<u8>) {
399        let (img_width, img_height) = img.dimensions();
400        let x_end = (ann.x + ann.width).min(img_width.saturating_sub(1));
401        let y_end = (ann.y + ann.height).min(img_height.saturating_sub(1));
402
403        // Top and bottom edges
404        for x in ann.x..=x_end {
405            if ann.y < img_height {
406                img.put_pixel(x, ann.y, color);
407            }
408            if y_end < img_height {
409                img.put_pixel(x, y_end, color);
410            }
411        }
412
413        // Left and right edges
414        for y in ann.y..=y_end {
415            if ann.x < img_width {
416                img.put_pixel(ann.x, y, color);
417            }
418            if x_end < img_width {
419                img.put_pixel(x_end, y, color);
420            }
421        }
422    }
423
424    /// Draw a filled rectangle
425    fn draw_filled_rectangle(img: &mut RgbaImage, ann: &Annotation, color: Rgba<u8>) {
426        let (img_width, img_height) = img.dimensions();
427        let x_end = (ann.x + ann.width).min(img_width);
428        let y_end = (ann.y + ann.height).min(img_height);
429
430        for y in ann.y..y_end {
431            for x in ann.x..x_end {
432                img.put_pixel(x, y, color);
433            }
434        }
435    }
436
437    /// Draw a highlight (semi-transparent overlay with alpha blending)
438    fn draw_highlight(img: &mut RgbaImage, ann: &Annotation) {
439        let (img_width, img_height) = img.dimensions();
440        let x_end = (ann.x + ann.width).min(img_width);
441        let y_end = (ann.y + ann.height).min(img_height);
442        let highlight_color = ann.color;
443        let alpha = f32::from(highlight_color[3]) / 255.0;
444
445        for y in ann.y..y_end {
446            for x in ann.x..x_end {
447                let pixel = img.get_pixel(x, y);
448                let blended = Rgba([
449                    blend_channel(pixel[0], highlight_color[0], alpha),
450                    blend_channel(pixel[1], highlight_color[1], alpha),
451                    blend_channel(pixel[2], highlight_color[2], alpha),
452                    255,
453                ]);
454                img.put_pixel(x, y, blended);
455            }
456        }
457    }
458}
459
460/// Blend two color channels with alpha
461#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
462fn blend_channel(base: u8, overlay: u8, alpha: f32) -> u8 {
463    let result = f32::from(base).mul_add(1.0 - alpha, f32::from(overlay) * alpha);
464    result.clamp(0.0, 255.0) as u8
465}
466
467// ============================================================================
468// EXTREME TDD: Tests written FIRST per spec
469// ============================================================================
470
471#[cfg(test)]
472#[allow(clippy::unwrap_used, clippy::expect_used)]
473mod tests {
474    use super::*;
475    use image::ImageFormat;
476    use std::io::Cursor;
477
478    fn create_test_screenshot(width: u32, height: u32, color: [u8; 4]) -> Screenshot {
479        let mut img = image::RgbaImage::new(width, height);
480        for pixel in img.pixels_mut() {
481            *pixel = Rgba(color);
482        }
483
484        let mut png_data = Vec::new();
485        img.write_to(&mut Cursor::new(&mut png_data), ImageFormat::Png)
486            .unwrap();
487
488        Screenshot::new(png_data, width, height)
489    }
490
491    mod compression_level_tests {
492        use super::*;
493
494        #[test]
495        fn test_default_compression() {
496            let level = CompressionLevel::default();
497            assert_eq!(level, CompressionLevel::Default);
498        }
499
500        #[test]
501        fn test_compression_levels() {
502            // Just verify they exist and can be created
503            let _ = CompressionLevel::None;
504            let _ = CompressionLevel::Fast;
505            let _ = CompressionLevel::Default;
506            let _ = CompressionLevel::Best;
507        }
508    }
509
510    mod png_metadata_tests {
511        use super::*;
512
513        #[test]
514        fn test_default_metadata() {
515            let meta = PngMetadata::default();
516            assert!(meta.title.is_none());
517            assert!(meta.description.is_none());
518            assert!(meta.timestamp.is_none());
519            assert!(meta.test_name.is_none());
520        }
521
522        #[test]
523        fn test_with_title() {
524            let meta = PngMetadata::new().with_title("Test Screenshot");
525            assert_eq!(meta.title, Some("Test Screenshot".to_string()));
526        }
527
528        #[test]
529        fn test_with_description() {
530            let meta = PngMetadata::new().with_description("Login page after error");
531            assert_eq!(meta.description, Some("Login page after error".to_string()));
532        }
533
534        #[test]
535        fn test_with_test_name() {
536            let meta = PngMetadata::new().with_test_name("test_login_failure");
537            assert_eq!(meta.test_name, Some("test_login_failure".to_string()));
538        }
539
540        #[test]
541        fn test_chained_builders() {
542            let meta = PngMetadata::new()
543                .with_title("Title")
544                .with_description("Description")
545                .with_test_name("test_name")
546                .with_software("Probar Test");
547
548            assert_eq!(meta.title, Some("Title".to_string()));
549            assert_eq!(meta.description, Some("Description".to_string()));
550            assert_eq!(meta.test_name, Some("test_name".to_string()));
551            assert_eq!(meta.software, Some("Probar Test".to_string()));
552        }
553    }
554
555    mod annotation_tests {
556        use super::*;
557
558        #[test]
559        fn test_rectangle_annotation() {
560            let ann = Annotation::rectangle(10, 20, 100, 50);
561            assert_eq!(ann.x, 10);
562            assert_eq!(ann.y, 20);
563            assert_eq!(ann.width, 100);
564            assert_eq!(ann.height, 50);
565            assert!(matches!(ann.kind, AnnotationKind::Rectangle));
566        }
567
568        #[test]
569        fn test_highlight_annotation() {
570            let ann = Annotation::highlight(0, 0, 50, 50);
571            assert!(matches!(ann.kind, AnnotationKind::Highlight));
572            assert_eq!(ann.color[3], 128); // Semi-transparent
573        }
574
575        #[test]
576        fn test_with_color() {
577            let ann = Annotation::rectangle(0, 0, 10, 10).with_color(0, 255, 0, 255);
578            assert_eq!(ann.color, [0, 255, 0, 255]);
579        }
580
581        #[test]
582        fn test_with_label() {
583            let ann = Annotation::rectangle(0, 0, 10, 10).with_label("Error Button");
584            assert_eq!(ann.label, Some("Error Button".to_string()));
585        }
586    }
587
588    mod png_exporter_tests {
589        use super::*;
590
591        #[test]
592        fn test_new_exporter() {
593            let exporter = PngExporter::new();
594            assert_eq!(exporter.compression(), CompressionLevel::Default);
595        }
596
597        #[test]
598        fn test_with_compression() {
599            let exporter = PngExporter::new().with_compression(CompressionLevel::Best);
600            assert_eq!(exporter.compression(), CompressionLevel::Best);
601        }
602
603        #[test]
604        fn test_with_metadata() {
605            let meta = PngMetadata::new().with_title("Test");
606            let exporter = PngExporter::new().with_metadata(meta);
607            assert_eq!(exporter.metadata().title, Some("Test".to_string()));
608        }
609
610        #[test]
611        fn test_export() {
612            let exporter = PngExporter::new();
613            let screenshot = create_test_screenshot(100, 100, [255, 0, 0, 255]);
614
615            let result = exporter.export(&screenshot);
616            assert!(result.is_ok());
617
618            let png_data = result.unwrap();
619            assert!(!png_data.is_empty());
620            // PNG magic bytes
621            assert_eq!(&png_data[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
622        }
623
624        #[test]
625        fn test_export_with_annotations() {
626            let exporter = PngExporter::new();
627            let screenshot = create_test_screenshot(100, 100, [255, 255, 255, 255]);
628
629            let annotations = vec![
630                Annotation::rectangle(10, 10, 30, 30).with_color(255, 0, 0, 255),
631                Annotation::highlight(50, 50, 40, 40),
632            ];
633
634            let result = exporter.export_with_annotations(&screenshot, &annotations);
635            assert!(result.is_ok());
636        }
637
638        #[test]
639        fn test_save() {
640            let exporter = PngExporter::new();
641            let screenshot = create_test_screenshot(50, 50, [0, 255, 0, 255]);
642
643            let temp_dir = tempfile::tempdir().unwrap();
644            let path = temp_dir.path().join("test.png");
645
646            let result = exporter.save(&screenshot, &path);
647            assert!(result.is_ok());
648            assert!(path.exists());
649
650            // Verify it's a valid PNG
651            let data = std::fs::read(&path).unwrap();
652            assert_eq!(&data[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
653        }
654
655        #[test]
656        fn test_save_with_annotations() {
657            let exporter = PngExporter::new();
658            let screenshot = create_test_screenshot(100, 100, [255, 255, 255, 255]);
659            let annotations = vec![Annotation::rectangle(10, 10, 20, 20)];
660
661            let temp_dir = tempfile::tempdir().unwrap();
662            let path = temp_dir.path().join("annotated.png");
663
664            let result = exporter.save_with_annotations(&screenshot, &annotations, &path);
665            assert!(result.is_ok());
666            assert!(path.exists());
667        }
668
669        #[test]
670        fn test_compression_levels_produce_different_sizes() {
671            let screenshot = create_test_screenshot(200, 200, [128, 128, 128, 255]);
672
673            let fast = PngExporter::new()
674                .with_compression(CompressionLevel::Fast)
675                .export(&screenshot)
676                .unwrap();
677
678            let best = PngExporter::new()
679                .with_compression(CompressionLevel::Best)
680                .export(&screenshot)
681                .unwrap();
682
683            // Best compression should generally produce smaller or equal output
684            // (may be equal for very simple images)
685            assert!(best.len() <= fast.len() + 100); // Allow small variance
686        }
687    }
688
689    mod blend_tests {
690        use super::*;
691
692        #[test]
693        fn test_blend_full_alpha() {
694            // Full alpha = overlay completely
695            assert_eq!(blend_channel(100, 200, 1.0), 200);
696        }
697
698        #[test]
699        fn test_blend_zero_alpha() {
700            // Zero alpha = base only
701            assert_eq!(blend_channel(100, 200, 0.0), 100);
702        }
703
704        #[test]
705        fn test_blend_half_alpha() {
706            // Half alpha = average
707            let result = blend_channel(100, 200, 0.5);
708            assert!((145..=155).contains(&result)); // ~150
709        }
710    }
711
712    mod annotation_kind_tests {
713        use super::*;
714
715        #[test]
716        fn test_filled_rectangle_annotation() {
717            let exporter = PngExporter::new();
718            let screenshot = create_test_screenshot(100, 100, [255, 255, 255, 255]);
719
720            let annotations = vec![Annotation {
721                x: 10,
722                y: 10,
723                width: 30,
724                height: 30,
725                color: [0, 0, 255, 255],
726                kind: AnnotationKind::FilledRectangle,
727                label: None,
728            }];
729
730            let result = exporter.export_with_annotations(&screenshot, &annotations);
731            assert!(result.is_ok());
732        }
733
734        #[test]
735        fn test_circle_annotation() {
736            let exporter = PngExporter::new();
737            let screenshot = create_test_screenshot(100, 100, [255, 255, 255, 255]);
738
739            let annotations = vec![Annotation {
740                x: 20,
741                y: 20,
742                width: 40,
743                height: 40,
744                color: [255, 0, 255, 255],
745                kind: AnnotationKind::Circle,
746                label: None,
747            }];
748
749            let result = exporter.export_with_annotations(&screenshot, &annotations);
750            assert!(result.is_ok());
751        }
752
753        #[test]
754        fn test_arrow_annotation() {
755            let exporter = PngExporter::new();
756            let screenshot = create_test_screenshot(100, 100, [255, 255, 255, 255]);
757
758            let annotations = vec![Annotation {
759                x: 10,
760                y: 10,
761                width: 50,
762                height: 20,
763                color: [0, 255, 0, 255],
764                kind: AnnotationKind::Arrow,
765                label: Some("Click here".to_string()),
766            }];
767
768            let result = exporter.export_with_annotations(&screenshot, &annotations);
769            assert!(result.is_ok());
770        }
771    }
772
773    mod exporter_edge_cases {
774        use super::*;
775
776        #[test]
777        fn test_exporter_default() {
778            let exporter = PngExporter::default();
779            assert_eq!(exporter.compression(), CompressionLevel::Default);
780        }
781
782        #[test]
783        fn test_compression_none() {
784            let exporter = PngExporter::new().with_compression(CompressionLevel::None);
785            let screenshot = create_test_screenshot(50, 50, [128, 128, 128, 255]);
786
787            let result = exporter.export(&screenshot);
788            assert!(result.is_ok());
789        }
790
791        #[test]
792        fn test_metadata_with_timestamp() {
793            let meta = PngMetadata::new().with_timestamp(SystemTime::now());
794            assert!(meta.timestamp.is_some());
795        }
796
797        #[test]
798        fn test_annotation_at_image_boundary() {
799            let exporter = PngExporter::new();
800            let screenshot = create_test_screenshot(100, 100, [255, 255, 255, 255]);
801
802            // Annotation extends beyond image bounds - should be clipped
803            let annotations = vec![Annotation::rectangle(80, 80, 50, 50)];
804
805            let result = exporter.export_with_annotations(&screenshot, &annotations);
806            assert!(result.is_ok());
807        }
808
809        #[test]
810        fn test_multiple_annotation_types() {
811            let exporter = PngExporter::new();
812            let screenshot = create_test_screenshot(200, 200, [200, 200, 200, 255]);
813
814            let annotations = vec![
815                Annotation::rectangle(10, 10, 30, 30),
816                Annotation::highlight(50, 10, 30, 30),
817                Annotation {
818                    x: 90,
819                    y: 10,
820                    width: 30,
821                    height: 30,
822                    color: [0, 255, 0, 255],
823                    kind: AnnotationKind::FilledRectangle,
824                    label: None,
825                },
826                Annotation {
827                    x: 130,
828                    y: 10,
829                    width: 30,
830                    height: 30,
831                    color: [255, 0, 255, 255],
832                    kind: AnnotationKind::Circle,
833                    label: None,
834                },
835            ];
836
837            let result = exporter.export_with_annotations(&screenshot, &annotations);
838            assert!(result.is_ok());
839        }
840
841        #[test]
842        fn test_all_compression_to_png_conversion() {
843            // Test all compression levels convert correctly
844            let _ = CompressionLevel::None.to_png_compression();
845            let _ = CompressionLevel::Fast.to_png_compression();
846            let _ = CompressionLevel::Default.to_png_compression();
847            let _ = CompressionLevel::Best.to_png_compression();
848        }
849    }
850
851    mod property_tests {
852        use super::*;
853        use proptest::prelude::*;
854
855        proptest! {
856            #[test]
857            fn prop_export_produces_valid_png(
858                width in 1u32..100,
859                height in 1u32..100,
860                r in 0u8..=255,
861                g in 0u8..=255,
862                b in 0u8..=255
863            ) {
864                let exporter = PngExporter::new();
865                let screenshot = create_test_screenshot(width, height, [r, g, b, 255]);
866
867                let result = exporter.export(&screenshot);
868                prop_assert!(result.is_ok());
869
870                let png_data = result.unwrap();
871                // PNG magic bytes
872                prop_assert_eq!(&png_data[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
873            }
874
875            #[test]
876            fn prop_annotation_bounds_respected(
877                x in 0u32..100,
878                y in 0u32..100,
879                w in 1u32..50,
880                h in 1u32..50
881            ) {
882                let ann = Annotation::rectangle(x, y, w, h);
883                prop_assert_eq!(ann.x, x);
884                prop_assert_eq!(ann.y, y);
885                prop_assert_eq!(ann.width, w);
886                prop_assert_eq!(ann.height, h);
887            }
888
889            #[test]
890            fn prop_blend_channel_in_range(
891                base in 0u8..=255,
892                overlay in 0u8..=255,
893                alpha in 0.0f32..=1.0
894            ) {
895                let result = blend_channel(base, overlay, alpha);
896                // Verify the blending is bounded (result is u8, so always <= 255)
897                // Instead verify the blend is within expected range
898                let expected_min = base.min(overlay);
899                let expected_max = base.max(overlay);
900                prop_assert!(result >= expected_min || alpha < 1.0);
901                prop_assert!(result <= expected_max || alpha > 0.0);
902            }
903        }
904    }
905}