Skip to main content

jugar_probar/pixel_coverage/
tracker.rs

1//! Pixel Coverage Tracker Implementation
2//!
3//! Tracks which grid cells have been exercised during testing.
4
5use serde::{Deserialize, Serialize};
6
7/// A point in screen coordinates
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub struct Point {
10    /// X coordinate (pixels from left)
11    pub x: u32,
12    /// Y coordinate (pixels from top)
13    pub y: u32,
14}
15
16impl Point {
17    /// Create a new point
18    #[must_use]
19    pub const fn new(x: u32, y: u32) -> Self {
20        Self { x, y }
21    }
22}
23
24/// A rectangular region in screen coordinates
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Region {
27    /// X coordinate of top-left corner
28    pub x: u32,
29    /// Y coordinate of top-left corner
30    pub y: u32,
31    /// Width in pixels
32    pub width: u32,
33    /// Height in pixels
34    pub height: u32,
35}
36
37impl Region {
38    /// Create a new region
39    #[must_use]
40    pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
41        Self {
42            x,
43            y,
44            width,
45            height,
46        }
47    }
48
49    /// Check if a point is within this region
50    #[must_use]
51    pub fn contains(&self, point: Point) -> bool {
52        point.x >= self.x
53            && point.x < self.x + self.width
54            && point.y >= self.y
55            && point.y < self.y + self.height
56    }
57
58    /// Get the area of this region
59    #[must_use]
60    pub fn area(&self) -> u64 {
61        u64::from(self.width) * u64::from(self.height)
62    }
63}
64
65/// Grid configuration
66#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
67pub struct GridConfig {
68    /// Screen width in pixels
69    pub screen_width: u32,
70    /// Screen height in pixels
71    pub screen_height: u32,
72    /// Number of grid columns
73    pub grid_cols: u32,
74    /// Number of grid rows
75    pub grid_rows: u32,
76}
77
78impl GridConfig {
79    /// Calculate cell width in pixels
80    #[must_use]
81    pub fn cell_width(&self) -> u32 {
82        self.screen_width / self.grid_cols
83    }
84
85    /// Calculate cell height in pixels
86    #[must_use]
87    pub fn cell_height(&self) -> u32 {
88        self.screen_height / self.grid_rows
89    }
90
91    /// Convert screen coordinates to grid cell
92    #[must_use]
93    pub fn point_to_cell(&self, point: Point) -> (u32, u32) {
94        let col = (point.x / self.cell_width()).min(self.grid_cols - 1);
95        let row = (point.y / self.cell_height()).min(self.grid_rows - 1);
96        (col, row)
97    }
98
99    /// Convert grid cell to screen region
100    #[must_use]
101    pub fn cell_to_region(&self, col: u32, row: u32) -> Region {
102        Region::new(
103            col * self.cell_width(),
104            row * self.cell_height(),
105            self.cell_width(),
106            self.cell_height(),
107        )
108    }
109}
110
111/// A single coverage cell in the grid
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113pub struct CoverageCell {
114    /// Number of times this cell was interacted with
115    pub hit_count: u64,
116    /// Coverage value (0.0 - 1.0)
117    pub coverage: f32,
118}
119
120impl CoverageCell {
121    /// Check if cell is covered
122    #[must_use]
123    pub fn is_covered(&self) -> bool {
124        self.hit_count > 0
125    }
126}
127
128/// Pixel coverage tracker for grid-based UI coverage
129#[derive(Debug, Clone)]
130pub struct PixelCoverageTracker {
131    config: GridConfig,
132    cells: Vec<Vec<CoverageCell>>,
133    threshold: f32,
134    total_interactions: u64,
135}
136
137impl PixelCoverageTracker {
138    /// Create a new tracker with given resolution and grid size
139    #[must_use]
140    pub fn new(width: u32, height: u32, grid_cols: u32, grid_rows: u32) -> Self {
141        let config = GridConfig {
142            screen_width: width,
143            screen_height: height,
144            grid_cols,
145            grid_rows,
146        };
147
148        let cells = (0..grid_rows)
149            .map(|_| (0..grid_cols).map(|_| CoverageCell::default()).collect())
150            .collect();
151
152        Self {
153            config,
154            cells,
155            threshold: 0.8,
156            total_interactions: 0,
157        }
158    }
159
160    /// Create a builder for more complex configuration
161    #[must_use]
162    pub fn builder() -> PixelCoverageTrackerBuilder {
163        PixelCoverageTrackerBuilder::default()
164    }
165
166    /// Get screen resolution
167    #[must_use]
168    pub fn resolution(&self) -> (u32, u32) {
169        (self.config.screen_width, self.config.screen_height)
170    }
171
172    /// Get grid size
173    #[must_use]
174    pub fn grid_size(&self) -> (u32, u32) {
175        (self.config.grid_cols, self.config.grid_rows)
176    }
177
178    /// Get coverage threshold
179    #[must_use]
180    pub fn threshold(&self) -> f32 {
181        self.threshold
182    }
183
184    /// Get grid configuration
185    #[must_use]
186    pub fn grid_config(&self) -> &GridConfig {
187        &self.config
188    }
189
190    /// Record an interaction at a point
191    pub fn record_interaction(&mut self, point: Point) {
192        let (col, row) = self.config.point_to_cell(point);
193        if let Some(row_cells) = self.cells.get_mut(row as usize) {
194            if let Some(cell) = row_cells.get_mut(col as usize) {
195                cell.hit_count += 1;
196                cell.coverage = 1.0;
197                self.total_interactions += 1;
198            }
199        }
200    }
201
202    /// Record coverage for a region
203    pub fn record_region(&mut self, region: Region) {
204        let start_col = region.x / self.config.cell_width();
205        let start_row = region.y / self.config.cell_height();
206        let end_col =
207            ((region.x + region.width) / self.config.cell_width()).min(self.config.grid_cols - 1);
208        let end_row =
209            ((region.y + region.height) / self.config.cell_height()).min(self.config.grid_rows - 1);
210
211        for row in start_row..=end_row {
212            for col in start_col..=end_col {
213                if let Some(row_cells) = self.cells.get_mut(row as usize) {
214                    if let Some(cell) = row_cells.get_mut(col as usize) {
215                        cell.hit_count += 1;
216                        cell.coverage = 1.0;
217                    }
218                }
219            }
220        }
221        self.total_interactions += 1;
222    }
223
224    /// Record coverage for an element by ID
225    pub fn record_element(&mut self, _id: &str, bounds: Region) {
226        self.record_region(bounds);
227    }
228
229    /// Generate coverage report
230    #[must_use]
231    pub fn generate_report(&self) -> PixelCoverageReport {
232        let total_cells = self.config.grid_cols * self.config.grid_rows;
233        let covered_cells = self
234            .cells
235            .iter()
236            .flat_map(|row| row.iter())
237            .filter(|cell| cell.is_covered())
238            .count() as u32;
239
240        let overall_coverage = if total_cells > 0 {
241            covered_cells as f32 / total_cells as f32
242        } else {
243            0.0
244        };
245
246        let min_coverage = self
247            .cells
248            .iter()
249            .flat_map(|row| row.iter())
250            .map(|c| c.coverage)
251            .fold(f32::MAX, f32::min);
252
253        let max_coverage = self
254            .cells
255            .iter()
256            .flat_map(|row| row.iter())
257            .map(|c| c.coverage)
258            .fold(0.0_f32, f32::max);
259
260        PixelCoverageReport {
261            grid_width: self.config.grid_cols,
262            grid_height: self.config.grid_rows,
263            overall_coverage,
264            covered_cells,
265            total_cells,
266            min_coverage: if !min_coverage.is_finite() || min_coverage > 1.0 {
267                0.0
268            } else {
269                min_coverage
270            },
271            max_coverage,
272            total_interactions: self.total_interactions,
273            meets_threshold: overall_coverage >= self.threshold,
274            uncovered_regions: self.find_uncovered_regions(),
275        }
276    }
277
278    /// Get list of uncovered regions
279    #[must_use]
280    pub fn uncovered_regions(&self) -> Vec<Region> {
281        self.find_uncovered_regions()
282    }
283
284    /// Find contiguous uncovered regions
285    fn find_uncovered_regions(&self) -> Vec<Region> {
286        let mut regions = Vec::new();
287
288        for (row_idx, row) in self.cells.iter().enumerate() {
289            for (col_idx, cell) in row.iter().enumerate() {
290                if !cell.is_covered() {
291                    // Convert to screen coordinates
292                    let region = self.config.cell_to_region(col_idx as u32, row_idx as u32);
293                    regions.push(region);
294                }
295            }
296        }
297
298        regions
299    }
300
301    /// Get cells for rendering
302    #[must_use]
303    pub fn cells(&self) -> &Vec<Vec<CoverageCell>> {
304        &self.cells
305    }
306
307    /// Generate terminal heatmap
308    #[must_use]
309    pub fn terminal_heatmap(&self) -> super::heatmap::TerminalHeatmap {
310        super::heatmap::TerminalHeatmap::from_tracker(self)
311    }
312
313    /// Generate PNG heatmap exporter
314    #[must_use]
315    pub fn png_heatmap(&self, width: u32, height: u32) -> super::heatmap::PngHeatmap {
316        super::heatmap::PngHeatmap::new(width, height)
317    }
318
319    /// Export to PNG bytes with default settings
320    #[cfg(feature = "media")]
321    pub fn export_png(&self, width: u32, height: u32) -> Result<Vec<u8>, std::io::Error> {
322        self.png_heatmap(width, height).export(&self.cells)
323    }
324
325    /// Export to PNG file with default settings
326    #[cfg(feature = "media")]
327    pub fn export_png_to_file(
328        &self,
329        width: u32,
330        height: u32,
331        path: &std::path::Path,
332    ) -> Result<(), std::io::Error> {
333        self.png_heatmap(width, height)
334            .export_to_file(&self.cells, path)
335    }
336}
337
338/// Builder for `PixelCoverageTracker`
339#[derive(Debug, Clone)]
340pub struct PixelCoverageTrackerBuilder {
341    width: u32,
342    height: u32,
343    grid_cols: u32,
344    grid_rows: u32,
345    threshold: f32,
346}
347
348impl Default for PixelCoverageTrackerBuilder {
349    fn default() -> Self {
350        Self {
351            width: 1920,
352            height: 1080,
353            grid_cols: 64,
354            grid_rows: 36,
355            threshold: 0.8,
356        }
357    }
358}
359
360impl PixelCoverageTrackerBuilder {
361    /// Set screen resolution
362    #[must_use]
363    pub fn resolution(mut self, width: u32, height: u32) -> Self {
364        self.width = width;
365        self.height = height;
366        self
367    }
368
369    /// Set grid size
370    #[must_use]
371    pub fn grid_size(mut self, cols: u32, rows: u32) -> Self {
372        self.grid_cols = cols;
373        self.grid_rows = rows;
374        self
375    }
376
377    /// Set coverage threshold
378    #[must_use]
379    pub fn threshold(mut self, threshold: f32) -> Self {
380        self.threshold = threshold;
381        self
382    }
383
384    /// Build the tracker
385    #[must_use]
386    pub fn build(self) -> PixelCoverageTracker {
387        let mut tracker =
388            PixelCoverageTracker::new(self.width, self.height, self.grid_cols, self.grid_rows);
389        tracker.threshold = self.threshold;
390        tracker
391    }
392}
393
394/// Pixel coverage report
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct PixelCoverageReport {
397    /// Grid width (columns)
398    pub grid_width: u32,
399    /// Grid height (rows)
400    pub grid_height: u32,
401    /// Overall coverage percentage (0.0 - 1.0)
402    pub overall_coverage: f32,
403    /// Number of covered cells
404    pub covered_cells: u32,
405    /// Total number of cells
406    pub total_cells: u32,
407    /// Minimum coverage in any cell
408    pub min_coverage: f32,
409    /// Maximum coverage in any cell
410    pub max_coverage: f32,
411    /// Total number of interactions recorded
412    pub total_interactions: u64,
413    /// Whether coverage meets the threshold
414    pub meets_threshold: bool,
415    /// List of uncovered regions
416    pub uncovered_regions: Vec<Region>,
417}
418
419impl Default for PixelCoverageReport {
420    fn default() -> Self {
421        Self {
422            grid_width: 0,
423            grid_height: 0,
424            overall_coverage: 0.0,
425            covered_cells: 0,
426            total_cells: 0,
427            min_coverage: 0.0,
428            max_coverage: 0.0,
429            total_interactions: 0,
430            meets_threshold: false,
431            uncovered_regions: Vec::new(),
432        }
433    }
434}
435
436impl PixelCoverageReport {
437    /// Get coverage as percentage (0-100)
438    #[must_use]
439    pub fn percent(&self) -> f32 {
440        self.overall_coverage * 100.0
441    }
442
443    /// Check if coverage is complete (100%)
444    #[must_use]
445    pub fn is_complete(&self) -> bool {
446        self.covered_cells == self.total_cells
447    }
448}
449
450/// Line/element coverage report (from GuiCoverage)
451#[derive(Debug, Clone, Default, Serialize, Deserialize)]
452pub struct LineCoverageReport {
453    /// Element coverage percentage (0.0 - 1.0)
454    pub element_coverage: f32,
455    /// Screen coverage percentage (0.0 - 1.0)
456    pub screen_coverage: f32,
457    /// Journey coverage percentage (0.0 - 1.0)
458    pub journey_coverage: f32,
459    /// Total elements tracked
460    pub total_elements: usize,
461    /// Covered elements
462    pub covered_elements: usize,
463}
464
465impl LineCoverageReport {
466    /// Create from component coverages
467    #[must_use]
468    pub fn new(
469        element_coverage: f32,
470        screen_coverage: f32,
471        journey_coverage: f32,
472        total_elements: usize,
473        covered_elements: usize,
474    ) -> Self {
475        Self {
476            element_coverage,
477            screen_coverage,
478            journey_coverage,
479            total_elements,
480            covered_elements,
481        }
482    }
483
484    /// Get average coverage across all dimensions
485    #[must_use]
486    pub fn average(&self) -> f32 {
487        (self.element_coverage + self.screen_coverage + self.journey_coverage) / 3.0
488    }
489}
490
491/// Combined coverage report (line + pixel coverage)
492#[derive(Debug, Clone, Default, Serialize, Deserialize)]
493pub struct CombinedCoverageReport {
494    /// Line/element coverage (logical)
495    pub line_coverage: LineCoverageReport,
496    /// Pixel/region coverage (visual)
497    pub pixel_coverage: PixelCoverageReport,
498    /// Overall score (weighted average)
499    pub overall_score: f32,
500    /// Meets threshold
501    pub meets_threshold: bool,
502    /// Weight for line coverage (0.0 - 1.0)
503    pub line_weight: f32,
504    /// Weight for pixel coverage (0.0 - 1.0)
505    pub pixel_weight: f32,
506}
507
508impl CombinedCoverageReport {
509    /// Default weight for line coverage (50%)
510    pub const DEFAULT_LINE_WEIGHT: f32 = 0.5;
511    /// Default weight for pixel coverage (50%)
512    pub const DEFAULT_PIXEL_WEIGHT: f32 = 0.5;
513    /// Default threshold for meeting coverage requirements (80%)
514    pub const DEFAULT_THRESHOLD: f32 = 0.8;
515
516    /// Create from line and pixel reports with default weights
517    #[must_use]
518    pub fn from_parts(line: LineCoverageReport, pixel: PixelCoverageReport) -> Self {
519        Self::from_parts_weighted(
520            line,
521            pixel,
522            Self::DEFAULT_LINE_WEIGHT,
523            Self::DEFAULT_PIXEL_WEIGHT,
524        )
525    }
526
527    /// Create from line and pixel reports with custom weights
528    #[must_use]
529    pub fn from_parts_weighted(
530        line: LineCoverageReport,
531        pixel: PixelCoverageReport,
532        line_weight: f32,
533        pixel_weight: f32,
534    ) -> Self {
535        let line_score = line.element_coverage;
536        let pixel_score = pixel.overall_coverage;
537        let overall_score = line_score * line_weight + pixel_score * pixel_weight;
538
539        Self {
540            line_coverage: line,
541            pixel_coverage: pixel,
542            overall_score,
543            meets_threshold: overall_score >= Self::DEFAULT_THRESHOLD,
544            line_weight,
545            pixel_weight,
546        }
547    }
548
549    /// Set threshold and update meets_threshold
550    #[must_use]
551    pub fn with_threshold(mut self, threshold: f32) -> Self {
552        self.meets_threshold = self.overall_score >= threshold;
553        self
554    }
555
556    /// Get line coverage percentage (0-100)
557    #[must_use]
558    pub fn line_percent(&self) -> f32 {
559        self.line_coverage.element_coverage * 100.0
560    }
561
562    /// Get pixel coverage percentage (0-100)
563    #[must_use]
564    pub fn pixel_percent(&self) -> f32 {
565        self.pixel_coverage.overall_coverage * 100.0
566    }
567
568    /// Get overall score percentage (0-100)
569    #[must_use]
570    pub fn overall_percent(&self) -> f32 {
571        self.overall_score * 100.0
572    }
573
574    /// Generate text summary
575    #[must_use]
576    pub fn summary(&self) -> String {
577        format!(
578            "Combined Coverage Report\n\
579             ========================\n\
580             Line Coverage:  {:.1}% ({}/{} elements)\n\
581             Pixel Coverage: {:.1}% ({}/{} cells)\n\
582             Overall Score:  {:.1}%\n\
583             Threshold Met:  {}\n",
584            self.line_percent(),
585            self.line_coverage.covered_elements,
586            self.line_coverage.total_elements,
587            self.pixel_percent(),
588            self.pixel_coverage.covered_cells,
589            self.pixel_coverage.total_cells,
590            self.overall_percent(),
591            if self.meets_threshold { "✓" } else { "✗" }
592        )
593    }
594}
595
596#[cfg(test)]
597#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn test_grid_config_cell_dimensions() {
603        let config = GridConfig {
604            screen_width: 1920,
605            screen_height: 1080,
606            grid_cols: 64,
607            grid_rows: 36,
608        };
609
610        assert_eq!(config.cell_width(), 30);
611        assert_eq!(config.cell_height(), 30);
612    }
613
614    #[test]
615    fn test_grid_config_point_to_cell() {
616        let config = GridConfig {
617            screen_width: 100,
618            screen_height: 100,
619            grid_cols: 10,
620            grid_rows: 10,
621        };
622
623        assert_eq!(config.point_to_cell(Point::new(0, 0)), (0, 0));
624        assert_eq!(config.point_to_cell(Point::new(15, 25)), (1, 2));
625        assert_eq!(config.point_to_cell(Point::new(99, 99)), (9, 9));
626    }
627
628    #[test]
629    fn test_coverage_cell_default() {
630        let cell = CoverageCell::default();
631        assert_eq!(cell.hit_count, 0);
632        assert!(!cell.is_covered());
633    }
634
635    #[test]
636    fn test_builder_defaults() {
637        let builder = PixelCoverageTrackerBuilder::default();
638        assert_eq!(builder.width, 1920);
639        assert_eq!(builder.height, 1080);
640        assert_eq!(builder.grid_cols, 64);
641        assert_eq!(builder.grid_rows, 36);
642    }
643
644    #[test]
645    fn test_report_percent() {
646        let report = PixelCoverageReport {
647            grid_width: 10,
648            grid_height: 10,
649            overall_coverage: 0.75,
650            covered_cells: 75,
651            total_cells: 100,
652            min_coverage: 0.0,
653            max_coverage: 1.0,
654            total_interactions: 100,
655            meets_threshold: false,
656            uncovered_regions: vec![],
657        };
658
659        assert!((report.percent() - 75.0).abs() < f32::EPSILON);
660    }
661
662    #[test]
663    fn test_report_is_complete() {
664        let complete = PixelCoverageReport {
665            grid_width: 10,
666            grid_height: 10,
667            overall_coverage: 1.0,
668            covered_cells: 100,
669            total_cells: 100,
670            min_coverage: 1.0,
671            max_coverage: 1.0,
672            total_interactions: 100,
673            meets_threshold: true,
674            uncovered_regions: vec![],
675        };
676
677        assert!(complete.is_complete());
678
679        let incomplete = PixelCoverageReport {
680            covered_cells: 99,
681            total_cells: 100,
682            ..complete
683        };
684
685        assert!(!incomplete.is_complete());
686    }
687
688    #[test]
689    fn test_region_area() {
690        let region = Region::new(0, 0, 100, 50);
691        assert_eq!(region.area(), 5000);
692    }
693
694    // =========================================================================
695    // Combined Coverage Report Tests
696    // =========================================================================
697
698    #[test]
699    fn h0_combined_01_from_parts() {
700        let line_report = LineCoverageReport::new(0.90, 1.0, 0.80, 22, 20);
701        let pixel_report = PixelCoverageReport {
702            overall_coverage: 0.85,
703            ..Default::default()
704        };
705
706        let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
707
708        // Weighted average: (0.90 * 0.5 + 0.85 * 0.5) = 0.875
709        assert!((combined.overall_score - 0.875).abs() < 0.01);
710        assert!(combined.meets_threshold);
711    }
712
713    #[test]
714    fn h0_combined_02_custom_weights() {
715        let line_report = LineCoverageReport::new(1.0, 1.0, 1.0, 10, 10);
716        let pixel_report = PixelCoverageReport {
717            overall_coverage: 0.0,
718            ..Default::default()
719        };
720
721        // 100% line weight, 0% pixel weight
722        let combined =
723            CombinedCoverageReport::from_parts_weighted(line_report, pixel_report, 1.0, 0.0);
724
725        assert!((combined.overall_score - 1.0).abs() < 0.01);
726    }
727
728    #[test]
729    fn h0_combined_03_threshold() {
730        let line_report = LineCoverageReport::new(0.5, 0.5, 0.5, 10, 5);
731        let pixel_report = PixelCoverageReport {
732            overall_coverage: 0.5,
733            ..Default::default()
734        };
735
736        let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
737
738        assert!(!combined.meets_threshold); // 0.5 < 0.8
739        assert!((combined.overall_score - 0.5).abs() < 0.01);
740
741        let relaxed = combined.with_threshold(0.4);
742        assert!(relaxed.meets_threshold);
743    }
744
745    #[test]
746    fn h0_combined_04_summary() {
747        let line_report = LineCoverageReport::new(0.90, 1.0, 0.80, 22, 20);
748        let pixel_report = PixelCoverageReport {
749            overall_coverage: 0.85,
750            covered_cells: 85,
751            total_cells: 100,
752            ..Default::default()
753        };
754
755        let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
756        let summary = combined.summary();
757
758        assert!(summary.contains("Line Coverage"));
759        assert!(summary.contains("Pixel Coverage"));
760        assert!(summary.contains("Overall Score"));
761        assert!(summary.contains("✓"));
762    }
763
764    #[test]
765    fn h0_combined_05_line_report_average() {
766        let report = LineCoverageReport::new(0.9, 0.6, 0.9, 10, 8);
767        assert!((report.average() - 0.8).abs() < 0.01);
768    }
769
770    #[test]
771    fn h0_combined_06_percentages() {
772        let line_report = LineCoverageReport::new(0.90, 1.0, 0.80, 22, 20);
773        let pixel_report = PixelCoverageReport {
774            overall_coverage: 0.85,
775            ..Default::default()
776        };
777
778        let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
779
780        assert!((combined.line_percent() - 90.0).abs() < 0.01);
781        assert!((combined.pixel_percent() - 85.0).abs() < 0.01);
782        assert!((combined.overall_percent() - 87.5).abs() < 0.01);
783    }
784
785    #[test]
786    fn h0_combined_07_default() {
787        let combined = CombinedCoverageReport::default();
788        assert_eq!(combined.overall_score, 0.0);
789        assert!(!combined.meets_threshold);
790    }
791
792    // =========================================================================
793    // PNG export convenience tests
794    // =========================================================================
795
796    #[test]
797    fn h0_tracker_png_export() {
798        let mut tracker = PixelCoverageTracker::new(100, 100, 10, 10);
799        tracker.record_region(Region::new(0, 0, 50, 50));
800
801        let png = tracker.export_png(200, 200).unwrap();
802        assert!(!png.is_empty());
803        // Verify PNG header
804        assert_eq!(&png[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
805    }
806
807    #[test]
808    fn h0_tracker_png_heatmap() {
809        let tracker = PixelCoverageTracker::new(100, 100, 10, 10);
810        let heatmap = tracker.png_heatmap(200, 200);
811        let png = heatmap.export(tracker.cells()).unwrap();
812        assert!(!png.is_empty());
813    }
814}