Skip to main content

jugar_probar/pixel_coverage/
heatmap.rs

1//! Heatmap Rendering for Pixel Coverage
2//!
3//! Renders coverage data as visual heatmaps for terminal and web output.
4
5use super::tracker::{CoverageCell, PixelCoverageTracker};
6use serde::{Deserialize, Serialize};
7
8/// RGB color
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub struct Rgb {
11    /// Red component (0-255)
12    pub r: u8,
13    /// Green component (0-255)
14    pub g: u8,
15    /// Blue component (0-255)
16    pub b: u8,
17}
18
19impl Rgb {
20    /// Create a new RGB color
21    #[must_use]
22    pub const fn new(r: u8, g: u8, b: u8) -> Self {
23        Self { r, g, b }
24    }
25
26    /// Create color from hex value
27    #[must_use]
28    pub const fn from_hex(hex: u32) -> Self {
29        Self {
30            r: ((hex >> 16) & 0xFF) as u8,
31            g: ((hex >> 8) & 0xFF) as u8,
32            b: (hex & 0xFF) as u8,
33        }
34    }
35}
36
37/// Color palette for heatmap rendering
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ColorPalette {
40    /// Color for 0% coverage
41    pub zero: Rgb,
42    /// Color for 25% coverage
43    pub low: Rgb,
44    /// Color for 50% coverage
45    pub medium: Rgb,
46    /// Color for 75% coverage
47    pub high: Rgb,
48    /// Color for 100% coverage
49    pub full: Rgb,
50}
51
52impl Default for ColorPalette {
53    fn default() -> Self {
54        Self::viridis()
55    }
56}
57
58impl ColorPalette {
59    /// Viridis color palette (colorblind-friendly)
60    #[must_use]
61    pub fn viridis() -> Self {
62        Self {
63            zero: Rgb::from_hex(0x440154),   // Dark purple
64            low: Rgb::from_hex(0x3B528B),    // Blue
65            medium: Rgb::from_hex(0x21918C), // Teal
66            high: Rgb::from_hex(0x5DC863),   // Green
67            full: Rgb::from_hex(0xFDE725),   // Yellow
68        }
69    }
70
71    /// Red-Yellow-Green palette (traffic light)
72    #[must_use]
73    pub fn traffic_light() -> Self {
74        Self {
75            zero: Rgb::from_hex(0xFF0000),   // Red
76            low: Rgb::from_hex(0xFF6600),    // Orange
77            medium: Rgb::from_hex(0xFFFF00), // Yellow
78            high: Rgb::from_hex(0x99FF00),   // Yellow-green
79            full: Rgb::from_hex(0x00FF00),   // Green
80        }
81    }
82
83    /// Get color for coverage value
84    #[must_use]
85    pub fn color_for_coverage(&self, coverage: f32) -> Rgb {
86        match coverage {
87            c if c <= 0.0 => self.zero,
88            c if c <= 0.25 => self.low,
89            c if c <= 0.50 => self.medium,
90            c if c <= 0.75 => self.high,
91            _ => self.full,
92        }
93    }
94}
95
96/// Terminal heatmap renderer
97#[derive(Debug, Clone)]
98pub struct TerminalHeatmap {
99    cells: Vec<Vec<f32>>,
100    palette: ColorPalette,
101    use_color: bool,
102}
103
104impl TerminalHeatmap {
105    /// Create from coverage tracker
106    #[must_use]
107    pub fn from_tracker(tracker: &PixelCoverageTracker) -> Self {
108        let cells = tracker
109            .cells()
110            .iter()
111            .map(|row| row.iter().map(|c| c.coverage).collect())
112            .collect();
113
114        Self {
115            cells,
116            palette: ColorPalette::default(),
117            use_color: true,
118        }
119    }
120
121    /// Create from raw coverage values
122    #[must_use]
123    pub fn from_values(cells: Vec<Vec<f32>>) -> Self {
124        Self {
125            cells,
126            palette: ColorPalette::default(),
127            use_color: true,
128        }
129    }
130
131    /// Set color palette
132    #[must_use]
133    pub fn with_palette(mut self, palette: ColorPalette) -> Self {
134        self.palette = palette;
135        self
136    }
137
138    /// Disable ANSI color output
139    #[must_use]
140    pub fn without_color(mut self) -> Self {
141        self.use_color = false;
142        self
143    }
144
145    /// Render to string
146    #[must_use]
147    pub fn render(&self) -> String {
148        let mut output = String::new();
149
150        for row in &self.cells {
151            for &coverage in row {
152                let char = Self::coverage_to_char(coverage);
153
154                if self.use_color {
155                    let color = self.palette.color_for_coverage(coverage);
156                    output.push_str(&format!(
157                        "\x1b[38;2;{};{};{}m{}\x1b[0m",
158                        color.r, color.g, color.b, char
159                    ));
160                } else {
161                    output.push(char);
162                }
163            }
164            output.push('\n');
165        }
166
167        output
168    }
169
170    /// Render with border
171    #[must_use]
172    pub fn render_with_border(&self) -> String {
173        let width = self.cells.first().map_or(0, Vec::len);
174        let mut output = String::new();
175
176        // Top border
177        output.push('┌');
178        for _ in 0..width {
179            output.push('─');
180        }
181        output.push_str("┐\n");
182
183        // Content
184        for row in &self.cells {
185            output.push('│');
186            for &coverage in row {
187                let char = Self::coverage_to_char(coverage);
188
189                if self.use_color {
190                    let color = self.palette.color_for_coverage(coverage);
191                    output.push_str(&format!(
192                        "\x1b[38;2;{};{};{}m{}\x1b[0m",
193                        color.r, color.g, color.b, char
194                    ));
195                } else {
196                    output.push(char);
197                }
198            }
199            output.push_str("│\n");
200        }
201
202        // Bottom border
203        output.push('└');
204        for _ in 0..width {
205            output.push('─');
206        }
207        output.push('┘');
208
209        output
210    }
211
212    /// Convert coverage value to Unicode block character
213    fn coverage_to_char(coverage: f32) -> char {
214        match coverage {
215            c if c <= 0.0 => ' ',  // Empty
216            c if c <= 0.25 => '░', // Light shade
217            c if c <= 0.50 => '▒', // Medium shade
218            c if c <= 0.75 => '▓', // Dark shade
219            _ => '█',              // Full block
220        }
221    }
222
223    /// Render legend
224    #[must_use]
225    pub fn legend(&self) -> String {
226        let mut legend = String::from("Legend:\n");
227
228        if self.use_color {
229            let c = &self.palette;
230            legend.push_str(&format!(
231                "  \x1b[38;2;{};{};{}m \x1b[0m = 0% (untested)\n",
232                c.zero.r, c.zero.g, c.zero.b
233            ));
234            legend.push_str(&format!(
235                "  \x1b[38;2;{};{};{}m░\x1b[0m = 1-25%\n",
236                c.low.r, c.low.g, c.low.b
237            ));
238            legend.push_str(&format!(
239                "  \x1b[38;2;{};{};{}m▒\x1b[0m = 26-50%\n",
240                c.medium.r, c.medium.g, c.medium.b
241            ));
242            legend.push_str(&format!(
243                "  \x1b[38;2;{};{};{}m▓\x1b[0m = 51-75%\n",
244                c.high.r, c.high.g, c.high.b
245            ));
246            legend.push_str(&format!(
247                "  \x1b[38;2;{};{};{}m█\x1b[0m = 76-100%\n",
248                c.full.r, c.full.g, c.full.b
249            ));
250        } else {
251            legend.push_str("    = 0% (untested)\n");
252            legend.push_str("  ░ = 1-25%\n");
253            legend.push_str("  ▒ = 26-50%\n");
254            legend.push_str("  ▓ = 51-75%\n");
255            legend.push_str("  █ = 76-100%\n");
256        }
257
258        legend
259    }
260}
261
262/// Heatmap renderer trait for extensibility
263pub trait HeatmapRenderer {
264    /// Render heatmap to string
265    fn render(&self, cells: &[Vec<CoverageCell>]) -> String;
266}
267
268/// PNG heatmap export with trueno-viz style output
269#[derive(Debug, Clone)]
270pub struct PngHeatmap {
271    /// Output width in pixels
272    width: u32,
273    /// Output height in pixels
274    height: u32,
275    /// Color palette
276    palette: ColorPalette,
277    /// Show legend color bar
278    show_legend: bool,
279    /// Highlight gaps with red outline
280    highlight_gaps: bool,
281    /// Show cell borders
282    show_borders: bool,
283    /// Border color (gray by default)
284    border_color: Rgb,
285    /// Title text
286    title: Option<String>,
287    /// Subtitle text (below title)
288    subtitle: Option<String>,
289    /// Margin around the heatmap (trueno-viz style)
290    margin: u32,
291    /// Background color
292    background: Rgb,
293    /// Stats panel for combined coverage display
294    pub stats_panel: Option<StatsPanel>,
295}
296
297impl Default for PngHeatmap {
298    fn default() -> Self {
299        Self::new(800, 600)
300    }
301}
302
303impl PngHeatmap {
304    /// Create new PNG exporter with specified dimensions
305    #[must_use]
306    pub fn new(width: u32, height: u32) -> Self {
307        Self {
308            width,
309            height,
310            palette: ColorPalette::default(),
311            show_legend: false,
312            highlight_gaps: false,
313            show_borders: true,
314            border_color: Rgb::new(80, 80, 80),
315            title: None,
316            subtitle: None,
317            margin: 40,
318            background: Rgb::new(255, 255, 255),
319            stats_panel: None,
320        }
321    }
322
323    /// Set margin around the heatmap (trueno-viz style)
324    #[must_use]
325    pub fn with_margin(mut self, margin: u32) -> Self {
326        self.margin = margin;
327        self
328    }
329
330    /// Set background color
331    #[must_use]
332    pub fn with_background(mut self, color: Rgb) -> Self {
333        self.background = color;
334        self
335    }
336
337    /// Set border color
338    #[must_use]
339    pub fn with_border_color(mut self, color: Rgb) -> Self {
340        self.border_color = color;
341        self
342    }
343
344    /// Set color palette
345    #[must_use]
346    pub fn with_palette(mut self, palette: ColorPalette) -> Self {
347        self.palette = palette;
348        self
349    }
350
351    /// Enable legend overlay
352    #[must_use]
353    pub fn with_legend(mut self) -> Self {
354        self.show_legend = true;
355        self
356    }
357
358    /// Enable gap highlighting (red outline for 0% coverage cells)
359    #[must_use]
360    pub fn with_gap_highlighting(mut self) -> Self {
361        self.highlight_gaps = true;
362        self
363    }
364
365    /// Enable or disable cell borders
366    #[must_use]
367    pub fn with_borders(mut self, show: bool) -> Self {
368        self.show_borders = show;
369        self
370    }
371
372    /// Set title text
373    #[must_use]
374    pub fn with_title(mut self, title: &str) -> Self {
375        self.title = Some(title.to_string());
376        self
377    }
378
379    /// Set subtitle text (displayed below title)
380    #[must_use]
381    pub fn with_subtitle(mut self, subtitle: &str) -> Self {
382        self.subtitle = Some(subtitle.to_string());
383        self
384    }
385
386    /// Set combined coverage stats panel
387    #[must_use]
388    pub fn with_combined_stats(mut self, report: &super::tracker::CombinedCoverageReport) -> Self {
389        self.stats_panel = Some(StatsPanel {
390            line_coverage: report.line_coverage.element_coverage * 100.0,
391            pixel_coverage: report.pixel_coverage.overall_coverage * 100.0,
392            overall_score: report.overall_score * 100.0,
393            line_details: (
394                report.line_coverage.covered_elements,
395                report.line_coverage.total_elements,
396            ),
397            pixel_details: (
398                report.pixel_coverage.covered_cells,
399                report.pixel_coverage.total_cells,
400            ),
401            meets_threshold: report.meets_threshold,
402        });
403        self
404    }
405
406    /// Export to PNG bytes (trueno-viz style with margins)
407    #[cfg(feature = "media")]
408    pub fn export(&self, cells: &[Vec<CoverageCell>]) -> Result<Vec<u8>, std::io::Error> {
409        use image::{ImageBuffer, Rgb as ImageRgb, RgbImage};
410        use std::io::Cursor;
411
412        let rows = cells.len();
413        let cols = cells.first().map_or(0, Vec::len);
414
415        if rows == 0 || cols == 0 {
416            // Return minimal 1x1 PNG
417            let img: RgbImage = ImageBuffer::new(1, 1);
418            let mut buffer = Cursor::new(Vec::new());
419            img.write_to(&mut buffer, image::ImageFormat::Png)
420                .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
421            return Ok(buffer.into_inner());
422        }
423
424        // Create image buffer and fill with background color
425        let mut img: RgbImage = ImageBuffer::new(self.width, self.height);
426        let bg = ImageRgb([self.background.r, self.background.g, self.background.b]);
427        for pixel in img.pixels_mut() {
428            *pixel = bg;
429        }
430
431        let font = BitmapFont::default();
432        let text_color = Rgb::new(0, 0, 0); // Black text
433
434        // Calculate title space
435        let title_space = if self.title.is_some() {
436            if self.subtitle.is_some() {
437                24 // Title + subtitle
438            } else {
439                12 // Title only
440            }
441        } else {
442            0
443        };
444
445        // Calculate stats panel space
446        let stats_space = if self.stats_panel.is_some() { 50 } else { 0 };
447
448        // Calculate plot area (with margins, trueno-viz style)
449        let legend_space = if self.show_legend { 30 } else { 0 };
450        let plot_width = self.width.saturating_sub(2 * self.margin);
451        let plot_height = self
452            .height
453            .saturating_sub(2 * self.margin + legend_space + title_space + stats_space);
454
455        // Render title if present
456        let content_y_offset = self.margin + title_space;
457        if let Some(title) = &self.title {
458            if !title.is_empty() {
459                let title_width = font.text_width(title);
460                let title_x = (self.width.saturating_sub(title_width)) / 2;
461                font.render_text(&mut img, title, title_x, self.margin / 2, text_color);
462            }
463        }
464
465        // Render subtitle if present
466        if let Some(subtitle) = &self.subtitle {
467            if !subtitle.is_empty() {
468                let subtitle_width = font.text_width(subtitle);
469                let subtitle_x = (self.width.saturating_sub(subtitle_width)) / 2;
470                let subtitle_y = self.margin / 2 + 10;
471                font.render_text(&mut img, subtitle, subtitle_x, subtitle_y, text_color);
472            }
473        }
474
475        // Calculate cell dimensions within the plot area
476        let cell_width = plot_width / cols as u32;
477        let cell_height = plot_height / rows as u32;
478
479        let border_rgb = ImageRgb([
480            self.border_color.r,
481            self.border_color.g,
482            self.border_color.b,
483        ]);
484
485        // Fill cells within the plot area
486        for (row_idx, row) in cells.iter().enumerate() {
487            for (col_idx, cell) in row.iter().enumerate() {
488                let x_start = self.margin + col_idx as u32 * cell_width;
489                let y_start = content_y_offset + row_idx as u32 * cell_height;
490                let x_end = (x_start + cell_width).min(self.margin + plot_width);
491                let y_end = (y_start + cell_height).min(content_y_offset + plot_height);
492
493                let color = self.palette.interpolate(cell.coverage);
494                let cell_rgb = ImageRgb([color.r, color.g, color.b]);
495
496                // Fill cell
497                for y in y_start..y_end {
498                    for x in x_start..x_end {
499                        if x < self.width && y < self.height {
500                            img.put_pixel(x, y, cell_rgb);
501                        }
502                    }
503                }
504
505                // Draw border if enabled
506                if self.show_borders {
507                    // Top border
508                    for x in x_start..x_end {
509                        if y_start < self.height {
510                            img.put_pixel(x, y_start, border_rgb);
511                        }
512                    }
513                    // Left border
514                    for y in y_start..y_end {
515                        if x_start < self.width {
516                            img.put_pixel(x_start, y, border_rgb);
517                        }
518                    }
519                    // Right border (last column)
520                    if col_idx == cols - 1 {
521                        for y in y_start..y_end {
522                            if x_end > 0 && x_end <= self.width {
523                                img.put_pixel(x_end - 1, y, border_rgb);
524                            }
525                        }
526                    }
527                    // Bottom border (last row)
528                    if row_idx == rows - 1 {
529                        for x in x_start..x_end {
530                            if y_end > 0 && y_end <= self.height {
531                                img.put_pixel(x, y_end - 1, border_rgb);
532                            }
533                        }
534                    }
535                }
536
537                // Highlight gaps with red outline if enabled
538                if self.highlight_gaps && cell.coverage <= 0.0 {
539                    let gap_color = ImageRgb([255, 0, 0]);
540                    // Draw thicker red border for gaps (3 pixels)
541                    for thickness in 0..3 {
542                        // Top border
543                        for x in x_start..x_end {
544                            if y_start + thickness < self.height {
545                                img.put_pixel(x, y_start + thickness, gap_color);
546                            }
547                        }
548                        // Bottom border
549                        if y_end > thickness {
550                            let y_bottom = y_end - 1 - thickness;
551                            for x in x_start..x_end {
552                                if y_bottom < self.height {
553                                    img.put_pixel(x, y_bottom, gap_color);
554                                }
555                            }
556                        }
557                        // Left border
558                        for y in y_start..y_end {
559                            if x_start + thickness < self.width {
560                                img.put_pixel(x_start + thickness, y, gap_color);
561                            }
562                        }
563                        // Right border
564                        if x_end > thickness {
565                            let x_right = x_end - 1 - thickness;
566                            for y in y_start..y_end {
567                                if x_right < self.width {
568                                    img.put_pixel(x_right, y, gap_color);
569                                }
570                            }
571                        }
572                    }
573                }
574            }
575        }
576
577        // Draw legend color bar if enabled
578        if self.show_legend {
579            let legend_height = 20;
580            let legend_y = self
581                .height
582                .saturating_sub(self.margin / 2 + legend_height + stats_space);
583            let legend_width = plot_width;
584            let legend_x_start = self.margin;
585
586            // Draw legend bar
587            for x in legend_x_start..(legend_x_start + legend_width) {
588                let coverage = (x - legend_x_start) as f32 / legend_width as f32;
589                let color = self.palette.interpolate(coverage);
590                for y in legend_y..(legend_y + legend_height).min(self.height) {
591                    img.put_pixel(x, y, ImageRgb([color.r, color.g, color.b]));
592                }
593            }
594
595            // Draw legend labels
596            font.render_text(
597                &mut img,
598                "0%",
599                legend_x_start,
600                legend_y + legend_height + 2,
601                text_color,
602            );
603            let label_100 = "100%";
604            let label_width = font.text_width(label_100);
605            font.render_text(
606                &mut img,
607                label_100,
608                legend_x_start + legend_width - label_width,
609                legend_y + legend_height + 2,
610                text_color,
611            );
612        }
613
614        // Draw stats panel if present
615        if let Some(stats) = &self.stats_panel {
616            let stats_y = self.height.saturating_sub(stats_space + self.margin / 4);
617            let stats_x = self.margin;
618
619            // Line coverage
620            let line_text = format!(
621                "Line: {:.1}% ({}/{})",
622                stats.line_coverage, stats.line_details.0, stats.line_details.1
623            );
624            font.render_text(&mut img, &line_text, stats_x, stats_y, text_color);
625
626            // Pixel coverage
627            let pixel_text = format!(
628                "Pixel: {:.1}% ({}/{})",
629                stats.pixel_coverage, stats.pixel_details.0, stats.pixel_details.1
630            );
631            font.render_text(&mut img, &pixel_text, stats_x, stats_y + 12, text_color);
632
633            // Overall score
634            let overall_text = format!("Overall: {:.1}%", stats.overall_score);
635            let threshold_indicator = if stats.meets_threshold {
636                " PASS"
637            } else {
638                " FAIL"
639            };
640            let full_text = format!("{}{}", overall_text, threshold_indicator);
641            font.render_text(&mut img, &full_text, stats_x, stats_y + 24, text_color);
642        }
643
644        // Encode to PNG
645        let mut buffer = Cursor::new(Vec::new());
646        img.write_to(&mut buffer, image::ImageFormat::Png)
647            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
648
649        Ok(buffer.into_inner())
650    }
651
652    /// Export to file
653    #[cfg(feature = "media")]
654    pub fn export_to_file(
655        &self,
656        cells: &[Vec<CoverageCell>],
657        path: &std::path::Path,
658    ) -> Result<(), std::io::Error> {
659        let bytes = self.export(cells)?;
660        std::fs::write(path, bytes)
661    }
662}
663
664impl ColorPalette {
665    /// Magma color palette (dark to bright)
666    #[must_use]
667    pub fn magma() -> Self {
668        Self {
669            zero: Rgb::from_hex(0x000004),   // Almost black
670            low: Rgb::from_hex(0x51127C),    // Dark purple
671            medium: Rgb::from_hex(0xB63679), // Magenta
672            high: Rgb::from_hex(0xFB8861),   // Orange
673            full: Rgb::from_hex(0xFCFDBF),   // Light yellow
674        }
675    }
676
677    /// Heat color palette (black-red-yellow-white)
678    #[must_use]
679    pub fn heat() -> Self {
680        Self {
681            zero: Rgb::from_hex(0x000000),   // Black
682            low: Rgb::from_hex(0x8B0000),    // Dark red
683            medium: Rgb::from_hex(0xFF4500), // Orange red
684            high: Rgb::from_hex(0xFFD700),   // Gold
685            full: Rgb::from_hex(0xFFFFFF),   // White
686        }
687    }
688
689    /// Interpolate color for any coverage value 0.0-1.0
690    /// Returns smooth gradient instead of discrete steps
691    #[must_use]
692    pub fn interpolate(&self, coverage: f32) -> Rgb {
693        let coverage = coverage.clamp(0.0, 1.0);
694
695        // Define color stops
696        let stops: [(f32, Rgb); 5] = [
697            (0.0, self.zero),
698            (0.25, self.low),
699            (0.5, self.medium),
700            (0.75, self.high),
701            (1.0, self.full),
702        ];
703
704        // Find the two stops to interpolate between
705        for i in 0..stops.len() - 1 {
706            let (t0, c0) = stops[i];
707            let (t1, c1) = stops[i + 1];
708
709            if coverage >= t0 && coverage <= t1 {
710                let t = (coverage - t0) / (t1 - t0);
711                return Rgb::lerp(c0, c1, t);
712            }
713        }
714
715        // Fallback to full coverage color
716        self.full
717    }
718}
719
720impl Rgb {
721    /// Linear interpolation between two colors
722    #[must_use]
723    pub fn lerp(c0: Rgb, c1: Rgb, t: f32) -> Rgb {
724        let t = t.clamp(0.0, 1.0);
725        Rgb {
726            r: (f32::from(c0.r) + (f32::from(c1.r) - f32::from(c0.r)) * t) as u8,
727            g: (f32::from(c0.g) + (f32::from(c1.g) - f32::from(c0.g)) * t) as u8,
728            b: (f32::from(c0.b) + (f32::from(c1.b) - f32::from(c0.b)) * t) as u8,
729        }
730    }
731}
732
733// =============================================================================
734// Bitmap Font for Text Rendering
735// =============================================================================
736
737/// Simple 5x7 bitmap font for PNG text rendering
738/// Each character is represented as a 7-element array of u8 (5 bits per row)
739#[derive(Debug, Clone)]
740pub struct BitmapFont {
741    /// Character width in pixels
742    char_width: u32,
743    /// Character height in pixels
744    char_height: u32,
745    /// Spacing between characters
746    spacing: u32,
747}
748
749impl Default for BitmapFont {
750    fn default() -> Self {
751        Self {
752            char_width: 5,
753            char_height: 7,
754            spacing: 1,
755        }
756    }
757}
758
759impl BitmapFont {
760    /// Get character width
761    #[must_use]
762    pub const fn char_width(&self) -> u32 {
763        self.char_width
764    }
765
766    /// Get character height
767    #[must_use]
768    pub const fn char_height(&self) -> u32 {
769        self.char_height
770    }
771
772    /// Get spacing between characters
773    #[must_use]
774    pub const fn spacing(&self) -> u32 {
775        self.spacing
776    }
777
778    /// Get text width in pixels
779    #[must_use]
780    pub fn text_width(&self, text: &str) -> u32 {
781        let len = text.chars().count() as u32;
782        if len == 0 {
783            return 0;
784        }
785        len * self.char_width + (len - 1) * self.spacing
786    }
787
788    /// Get glyph bitmap for a character (5x7 bits as Vec<bool>)
789    #[must_use]
790    pub fn glyph(&self, c: char) -> Vec<bool> {
791        let bitmap = Self::char_bitmap(c);
792        let mut result = Vec::with_capacity(35);
793        for row in &bitmap {
794            for bit in 0..5 {
795                result.push((row >> (4 - bit)) & 1 == 1);
796            }
797        }
798        result
799    }
800
801    /// Get raw bitmap data for character (7 rows, each u8 represents 5 bits)
802    #[must_use]
803    const fn char_bitmap(c: char) -> [u8; 7] {
804        match c {
805            // Uppercase letters
806            'A' => [
807                0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001,
808            ],
809            'B' => [
810                0b11110, 0b10001, 0b11110, 0b10001, 0b10001, 0b10001, 0b11110,
811            ],
812            'C' => [
813                0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110,
814            ],
815            'D' => [
816                0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110,
817            ],
818            'E' => [
819                0b11111, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000, 0b11111,
820            ],
821            'F' => [
822                0b11111, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000, 0b10000,
823            ],
824            'G' => [
825                0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110,
826            ],
827            'H' => [
828                0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001, 0b10001,
829            ],
830            'I' => [
831                0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110,
832            ],
833            'J' => [
834                0b00111, 0b00010, 0b00010, 0b00010, 0b10010, 0b10010, 0b01100,
835            ],
836            'K' => [
837                0b10001, 0b10010, 0b11100, 0b10010, 0b10001, 0b10001, 0b10001,
838            ],
839            'L' => [
840                0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111,
841            ],
842            'M' => [
843                0b10001, 0b11011, 0b10101, 0b10001, 0b10001, 0b10001, 0b10001,
844            ],
845            'N' => [
846                0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001,
847            ],
848            'O' => [
849                0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110,
850            ],
851            'P' => [
852                0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000,
853            ],
854            'Q' => [
855                0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b01110, 0b00001,
856            ],
857            'R' => [
858                0b11110, 0b10001, 0b10001, 0b11110, 0b10010, 0b10001, 0b10001,
859            ],
860            'S' => [
861                0b01110, 0b10001, 0b10000, 0b01110, 0b00001, 0b10001, 0b01110,
862            ],
863            'T' => [
864                0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100,
865            ],
866            'U' => [
867                0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110,
868            ],
869            'V' => [
870                0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100,
871            ],
872            'W' => [
873                0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001,
874            ],
875            'X' => [
876                0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001,
877            ],
878            'Y' => [
879                0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100,
880            ],
881            'Z' => [
882                0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111,
883            ],
884            // Lowercase (map to uppercase for simplicity)
885            'a'..='z' => Self::char_bitmap((c as u8 - 32) as char),
886            // Digits
887            '0' => [
888                0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110,
889            ],
890            '1' => [
891                0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110,
892            ],
893            '2' => [
894                0b01110, 0b10001, 0b00001, 0b00110, 0b01000, 0b10000, 0b11111,
895            ],
896            '3' => [
897                0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110,
898            ],
899            '4' => [
900                0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010,
901            ],
902            '5' => [
903                0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110,
904            ],
905            '6' => [
906                0b01110, 0b10000, 0b11110, 0b10001, 0b10001, 0b10001, 0b01110,
907            ],
908            '7' => [
909                0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000,
910            ],
911            '8' => [
912                0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110,
913            ],
914            '9' => [
915                0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00001, 0b01110,
916            ],
917            // Punctuation and symbols
918            ' ' => [
919                0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000,
920            ],
921            '.' => [
922                0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b01100, 0b01100,
923            ],
924            ',' => [
925                0b00000, 0b00000, 0b00000, 0b00000, 0b00110, 0b00100, 0b01000,
926            ],
927            ':' => [
928                0b00000, 0b01100, 0b01100, 0b00000, 0b01100, 0b01100, 0b00000,
929            ],
930            '-' => [
931                0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000,
932            ],
933            '_' => [
934                0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b11111,
935            ],
936            '/' => [
937                0b00001, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b10000,
938            ],
939            '%' => [
940                0b11001, 0b11010, 0b00010, 0b00100, 0b01000, 0b01011, 0b10011,
941            ],
942            '(' => [
943                0b00010, 0b00100, 0b01000, 0b01000, 0b01000, 0b00100, 0b00010,
944            ],
945            ')' => [
946                0b01000, 0b00100, 0b00010, 0b00010, 0b00010, 0b00100, 0b01000,
947            ],
948            '=' => [
949                0b00000, 0b00000, 0b11111, 0b00000, 0b11111, 0b00000, 0b00000,
950            ],
951            '+' => [
952                0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000,
953            ],
954            '*' => [
955                0b00000, 0b10101, 0b01110, 0b11111, 0b01110, 0b10101, 0b00000,
956            ],
957            '!' => [
958                0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00000, 0b00100,
959            ],
960            '?' => [
961                0b01110, 0b10001, 0b00001, 0b00110, 0b00100, 0b00000, 0b00100,
962            ],
963            // Default: empty
964            _ => [
965                0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000,
966            ],
967        }
968    }
969
970    /// Render text to an image buffer at the specified position
971    #[cfg(feature = "media")]
972    pub fn render_text(&self, img: &mut image::RgbImage, text: &str, x: u32, y: u32, color: Rgb) {
973        use image::Rgb as ImageRgb;
974
975        let text_color = ImageRgb([color.r, color.g, color.b]);
976        let mut cursor_x = x;
977
978        for c in text.chars() {
979            let bitmap = Self::char_bitmap(c);
980
981            for (row_idx, &row) in bitmap.iter().enumerate() {
982                for bit in 0..5 {
983                    if (row >> (4 - bit)) & 1 == 1 {
984                        let px = cursor_x + bit;
985                        let py = y + row_idx as u32;
986                        if px < img.width() && py < img.height() {
987                            img.put_pixel(px, py, text_color);
988                        }
989                    }
990                }
991            }
992
993            cursor_x += self.char_width + self.spacing;
994        }
995    }
996}
997
998/// Stats panel content for combined coverage display
999#[derive(Debug, Clone)]
1000pub struct StatsPanel {
1001    /// Line coverage percentage
1002    pub line_coverage: f32,
1003    /// Pixel coverage percentage
1004    pub pixel_coverage: f32,
1005    /// Overall score
1006    pub overall_score: f32,
1007    /// Line coverage details (covered/total)
1008    pub line_details: (usize, usize),
1009    /// Pixel coverage details (covered/total)
1010    pub pixel_details: (u32, u32),
1011    /// Whether threshold is met
1012    pub meets_threshold: bool,
1013}
1014
1015/// SVG heatmap export
1016#[allow(dead_code)]
1017#[derive(Debug, Clone)]
1018pub struct SvgHeatmap {
1019    width: u32,
1020    height: u32,
1021    palette: ColorPalette,
1022}
1023
1024#[allow(dead_code)]
1025impl SvgHeatmap {
1026    /// Create new SVG exporter
1027    #[must_use]
1028    pub fn new(width: u32, height: u32) -> Self {
1029        Self {
1030            width,
1031            height,
1032            palette: ColorPalette::default(),
1033        }
1034    }
1035
1036    /// Set color palette
1037    #[must_use]
1038    pub fn with_palette(mut self, palette: ColorPalette) -> Self {
1039        self.palette = palette;
1040        self
1041    }
1042
1043    /// Export to SVG string
1044    #[must_use]
1045    pub fn export(&self, cells: &[Vec<CoverageCell>]) -> String {
1046        let rows = cells.len();
1047        let cols = cells.first().map_or(0, Vec::len);
1048
1049        if rows == 0 || cols == 0 {
1050            return String::from("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>");
1051        }
1052
1053        let cell_width = self.width / cols as u32;
1054        let cell_height = self.height / rows as u32;
1055
1056        let mut svg = format!(
1057            r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">"#,
1058            self.width, self.height, self.width, self.height
1059        );
1060
1061        svg.push_str("\n  <style>.cell { stroke: #333; stroke-width: 0.5; }</style>\n");
1062
1063        for (row_idx, row) in cells.iter().enumerate() {
1064            for (col_idx, cell) in row.iter().enumerate() {
1065                let x = col_idx as u32 * cell_width;
1066                let y = row_idx as u32 * cell_height;
1067                let color = self.palette.color_for_coverage(cell.coverage);
1068
1069                svg.push_str(&format!(
1070                    r#"  <rect class="cell" x="{}" y="{}" width="{}" height="{}" fill="rgb({},{},{})"/>"#,
1071                    x, y, cell_width, cell_height, color.r, color.g, color.b
1072                ));
1073                svg.push('\n');
1074            }
1075        }
1076
1077        svg.push_str("</svg>");
1078        svg
1079    }
1080}
1081
1082// =============================================================================
1083// Visual Regression Testing Infrastructure
1084// =============================================================================
1085
1086/// Visual regression testing utilities for PNG heatmap output
1087#[cfg(test)]
1088pub mod visual_regression {
1089    use super::*;
1090    use std::collections::hash_map::DefaultHasher;
1091    use std::hash::{Hash, Hasher};
1092
1093    /// Reference checksum for deterministic PNG output
1094    #[allow(dead_code)]
1095    #[derive(Debug, Clone)]
1096    pub struct ReferenceChecksum {
1097        /// Hash of the PNG bytes
1098        pub checksum: u64,
1099        /// Description of the reference
1100        pub description: &'static str,
1101        /// Width of the reference image
1102        pub width: u32,
1103        /// Height of the reference image
1104        pub height: u32,
1105    }
1106
1107    /// Result of visual comparison
1108    #[derive(Debug)]
1109    pub struct ComparisonResult {
1110        /// Whether images match within tolerance
1111        pub matches: bool,
1112        /// Percentage of pixels that differ
1113        pub diff_percentage: f32,
1114        /// Maximum color difference found
1115        pub max_diff: u8,
1116        /// Number of differing pixels
1117        pub diff_count: usize,
1118        /// Total pixels compared
1119        #[allow(dead_code)]
1120        pub total_pixels: usize,
1121    }
1122
1123    /// Compare two PNG images pixel-by-pixel with tolerance
1124    ///
1125    /// # Arguments
1126    /// * `reference` - Reference PNG bytes
1127    /// * `generated` - Generated PNG bytes
1128    /// * `tolerance` - Per-channel color tolerance (0-255)
1129    ///
1130    /// # Returns
1131    /// `ComparisonResult` with match status and diff statistics
1132    pub fn compare_png_with_tolerance(
1133        reference: &[u8],
1134        generated: &[u8],
1135        tolerance: u8,
1136    ) -> Result<ComparisonResult, String> {
1137        use image::GenericImageView;
1138
1139        let ref_img = image::load_from_memory(reference)
1140            .map_err(|e| format!("Failed to load reference image: {}", e))?;
1141        let gen_img = image::load_from_memory(generated)
1142            .map_err(|e| format!("Failed to load generated image: {}", e))?;
1143
1144        // Check dimensions match
1145        if ref_img.dimensions() != gen_img.dimensions() {
1146            return Ok(ComparisonResult {
1147                matches: false,
1148                diff_percentage: 100.0,
1149                max_diff: 255,
1150                diff_count: (ref_img.width() * ref_img.height()) as usize,
1151                total_pixels: (ref_img.width() * ref_img.height()) as usize,
1152            });
1153        }
1154
1155        let (width, height) = ref_img.dimensions();
1156        let total_pixels = (width * height) as usize;
1157        let mut diff_count = 0;
1158        let mut max_diff: u8 = 0;
1159
1160        for y in 0..height {
1161            for x in 0..width {
1162                let ref_pixel = ref_img.get_pixel(x, y);
1163                let gen_pixel = gen_img.get_pixel(x, y);
1164
1165                // Compare RGB channels (ignore alpha)
1166                let diff_r = (ref_pixel[0] as i16 - gen_pixel[0] as i16).unsigned_abs() as u8;
1167                let diff_g = (ref_pixel[1] as i16 - gen_pixel[1] as i16).unsigned_abs() as u8;
1168                let diff_b = (ref_pixel[2] as i16 - gen_pixel[2] as i16).unsigned_abs() as u8;
1169
1170                let channel_max = diff_r.max(diff_g).max(diff_b);
1171                max_diff = max_diff.max(channel_max);
1172
1173                if channel_max > tolerance {
1174                    diff_count += 1;
1175                }
1176            }
1177        }
1178
1179        let diff_percentage = (diff_count as f32 / total_pixels as f32) * 100.0;
1180        let matches = diff_count == 0 || diff_percentage < 0.1; // Allow 0.1% variance
1181
1182        Ok(ComparisonResult {
1183            matches,
1184            diff_percentage,
1185            max_diff,
1186            diff_count,
1187            total_pixels,
1188        })
1189    }
1190
1191    /// Compute deterministic checksum for PNG bytes
1192    pub fn compute_checksum(png_bytes: &[u8]) -> u64 {
1193        let mut hasher = DefaultHasher::new();
1194        png_bytes.hash(&mut hasher);
1195        hasher.finish()
1196    }
1197
1198    /// Generate reference cells for testing (deterministic pattern)
1199    pub fn reference_gradient_cells(rows: usize, cols: usize) -> Vec<Vec<CoverageCell>> {
1200        let mut cells = Vec::with_capacity(rows);
1201        for row in 0..rows {
1202            let mut row_cells = Vec::with_capacity(cols);
1203            for col in 0..cols {
1204                let coverage = (row as f32 / (rows - 1).max(1) as f32
1205                    + col as f32 / (cols - 1).max(1) as f32)
1206                    / 2.0;
1207                row_cells.push(CoverageCell {
1208                    coverage,
1209                    hit_count: (coverage * 10.0) as u64,
1210                });
1211            }
1212            cells.push(row_cells);
1213        }
1214        cells
1215    }
1216
1217    /// Generate reference cells with gaps (deterministic pattern)
1218    pub fn reference_gap_cells(rows: usize, cols: usize) -> Vec<Vec<CoverageCell>> {
1219        let mut cells = reference_gradient_cells(rows, cols);
1220        // Add deterministic gaps
1221        if rows > 2 && cols > 2 {
1222            cells[rows / 2][cols / 2] = CoverageCell {
1223                coverage: 0.0,
1224                hit_count: 0,
1225            };
1226        }
1227        if rows > 4 && cols > 4 {
1228            cells[rows / 4][cols / 4] = CoverageCell {
1229                coverage: 0.0,
1230                hit_count: 0,
1231            };
1232        }
1233        cells
1234    }
1235
1236    /// Generate uniform cells for testing
1237    pub fn reference_uniform_cells(
1238        rows: usize,
1239        cols: usize,
1240        coverage: f32,
1241    ) -> Vec<Vec<CoverageCell>> {
1242        vec![
1243            vec![
1244                CoverageCell {
1245                    coverage,
1246                    hit_count: (coverage * 10.0) as u64,
1247                };
1248                cols
1249            ];
1250            rows
1251        ]
1252    }
1253}
1254
1255#[cfg(test)]
1256#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
1257mod tests {
1258    use super::*;
1259
1260    #[test]
1261    fn test_rgb_from_hex() {
1262        let red = Rgb::from_hex(0xFF0000);
1263        assert_eq!(red.r, 255);
1264        assert_eq!(red.g, 0);
1265        assert_eq!(red.b, 0);
1266
1267        let white = Rgb::from_hex(0xFFFFFF);
1268        assert_eq!(white.r, 255);
1269        assert_eq!(white.g, 255);
1270        assert_eq!(white.b, 255);
1271    }
1272
1273    #[test]
1274    fn test_color_palette_viridis() {
1275        let palette = ColorPalette::viridis();
1276        assert_ne!(palette.zero, palette.full);
1277    }
1278
1279    #[test]
1280    fn test_color_for_coverage() {
1281        let palette = ColorPalette::traffic_light();
1282
1283        assert_eq!(palette.color_for_coverage(0.0), palette.zero);
1284        assert_eq!(palette.color_for_coverage(0.1), palette.low);
1285        assert_eq!(palette.color_for_coverage(0.4), palette.medium);
1286        assert_eq!(palette.color_for_coverage(0.6), palette.high);
1287        assert_eq!(palette.color_for_coverage(1.0), palette.full);
1288    }
1289
1290    #[test]
1291    fn test_terminal_heatmap_render() {
1292        let cells = vec![vec![0.0, 0.25, 0.5], vec![0.75, 1.0, 0.0]];
1293
1294        let heatmap = TerminalHeatmap::from_values(cells).without_color();
1295        let rendered = heatmap.render();
1296
1297        assert!(rendered.contains(' ')); // 0% coverage
1298        assert!(rendered.contains('█')); // 100% coverage
1299    }
1300
1301    #[test]
1302    fn test_terminal_heatmap_with_border() {
1303        let cells = vec![vec![1.0, 1.0], vec![0.0, 0.0]];
1304
1305        let heatmap = TerminalHeatmap::from_values(cells).without_color();
1306        let rendered = heatmap.render_with_border();
1307
1308        assert!(rendered.contains('┌'));
1309        assert!(rendered.contains('┘'));
1310        assert!(rendered.contains('│'));
1311    }
1312
1313    #[test]
1314    fn test_coverage_to_char() {
1315        assert_eq!(TerminalHeatmap::coverage_to_char(0.0), ' ');
1316        assert_eq!(TerminalHeatmap::coverage_to_char(0.1), '░');
1317        assert_eq!(TerminalHeatmap::coverage_to_char(0.3), '▒');
1318        assert_eq!(TerminalHeatmap::coverage_to_char(0.6), '▓');
1319        assert_eq!(TerminalHeatmap::coverage_to_char(1.0), '█');
1320    }
1321
1322    #[test]
1323    fn test_svg_export() {
1324        let cells = vec![vec![CoverageCell {
1325            hit_count: 1,
1326            coverage: 1.0,
1327        }]];
1328
1329        let svg = SvgHeatmap::new(100, 100).export(&cells);
1330
1331        assert!(svg.starts_with("<svg"));
1332        assert!(svg.contains("<rect"));
1333        assert!(svg.ends_with("</svg>"));
1334    }
1335
1336    #[test]
1337    fn test_svg_empty_cells() {
1338        let cells: Vec<Vec<CoverageCell>> = vec![];
1339        let svg = SvgHeatmap::new(100, 100).export(&cells);
1340        assert!(svg.contains("</svg>"));
1341    }
1342
1343    #[test]
1344    fn test_legend() {
1345        let cells = vec![vec![1.0]];
1346        let heatmap = TerminalHeatmap::from_values(cells).without_color();
1347        let legend = heatmap.legend();
1348
1349        assert!(legend.contains("Legend:"));
1350        assert!(legend.contains("░"));
1351        assert!(legend.contains("█"));
1352    }
1353
1354    // =========================================================================
1355    // PNG Heatmap Tests (H₀-PNG-XX)
1356    // =========================================================================
1357
1358    #[test]
1359    fn h0_png_01_basic_render() {
1360        let cells = vec![vec![CoverageCell {
1361            coverage: 0.5,
1362            hit_count: 5,
1363        }]];
1364        let png = PngHeatmap::new(100, 100).export(&cells).unwrap();
1365        assert!(!png.is_empty());
1366        // Verify PNG header bytes
1367        assert_eq!(&png[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
1368    }
1369
1370    #[test]
1371    fn h0_png_02_color_interpolation() {
1372        let palette = ColorPalette::viridis();
1373        let color_0 = palette.interpolate(0.0);
1374        let color_50 = palette.interpolate(0.5);
1375        let color_100 = palette.interpolate(1.0);
1376
1377        // Should be distinct colors
1378        assert_ne!(color_0, color_50);
1379        assert_ne!(color_50, color_100);
1380    }
1381
1382    #[test]
1383    fn h0_png_03_gap_highlighting() {
1384        let mut cells = vec![
1385            vec![
1386                CoverageCell {
1387                    coverage: 1.0,
1388                    hit_count: 10,
1389                };
1390                10
1391            ];
1392            10
1393        ];
1394        cells[5][5] = CoverageCell {
1395            coverage: 0.0,
1396            hit_count: 0,
1397        }; // Gap
1398
1399        let png = PngHeatmap::new(100, 100)
1400            .with_gap_highlighting()
1401            .export(&cells)
1402            .unwrap();
1403
1404        // Should render successfully with gap highlighted
1405        assert!(!png.is_empty());
1406        // Verify PNG header
1407        assert_eq!(&png[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
1408    }
1409
1410    #[test]
1411    fn h0_png_04_magma_palette() {
1412        let palette = ColorPalette::magma();
1413        assert_ne!(palette.zero, palette.full);
1414        // Magma starts nearly black
1415        assert!(palette.zero.r < 10);
1416        assert!(palette.zero.g < 10);
1417    }
1418
1419    #[test]
1420    fn h0_png_05_heat_palette() {
1421        let palette = ColorPalette::heat();
1422        assert_ne!(palette.zero, palette.full);
1423        // Heat starts at black
1424        assert_eq!(palette.zero, Rgb::new(0, 0, 0));
1425        // Heat ends at white
1426        assert_eq!(palette.full, Rgb::new(255, 255, 255));
1427    }
1428
1429    #[test]
1430    fn h0_png_06_rgb_lerp() {
1431        let black = Rgb::new(0, 0, 0);
1432        let white = Rgb::new(255, 255, 255);
1433
1434        let mid = Rgb::lerp(black, white, 0.5);
1435        assert_eq!(mid.r, 127);
1436        assert_eq!(mid.g, 127);
1437        assert_eq!(mid.b, 127);
1438
1439        // Extremes
1440        assert_eq!(Rgb::lerp(black, white, 0.0), black);
1441        assert_eq!(Rgb::lerp(black, white, 1.0), white);
1442    }
1443
1444    #[test]
1445    fn h0_png_07_interpolate_boundaries() {
1446        let palette = ColorPalette::viridis();
1447
1448        // Exactly at boundaries
1449        let c0 = palette.interpolate(0.0);
1450        let c25 = palette.interpolate(0.25);
1451        let c50 = palette.interpolate(0.5);
1452        let c75 = palette.interpolate(0.75);
1453        let c100 = palette.interpolate(1.0);
1454
1455        assert_eq!(c0, palette.zero);
1456        assert_eq!(c25, palette.low);
1457        assert_eq!(c50, palette.medium);
1458        assert_eq!(c75, palette.high);
1459        assert_eq!(c100, palette.full);
1460    }
1461
1462    #[test]
1463    fn h0_png_08_interpolate_clamping() {
1464        let palette = ColorPalette::viridis();
1465
1466        // Out of range values should be clamped
1467        let below = palette.interpolate(-0.5);
1468        let above = palette.interpolate(1.5);
1469
1470        assert_eq!(below, palette.zero);
1471        assert_eq!(above, palette.full);
1472    }
1473
1474    #[test]
1475    fn h0_png_09_empty_cells() {
1476        let cells: Vec<Vec<CoverageCell>> = vec![];
1477        let png = PngHeatmap::new(100, 100).export(&cells).unwrap();
1478        // Should still produce valid PNG (1x1 fallback)
1479        assert!(!png.is_empty());
1480        assert_eq!(&png[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
1481    }
1482
1483    #[test]
1484    fn h0_png_10_with_legend() {
1485        let cells = vec![
1486            vec![
1487                CoverageCell {
1488                    coverage: 0.0,
1489                    hit_count: 0,
1490                },
1491                CoverageCell {
1492                    coverage: 1.0,
1493                    hit_count: 10,
1494                },
1495            ],
1496            vec![
1497                CoverageCell {
1498                    coverage: 0.5,
1499                    hit_count: 5,
1500                },
1501                CoverageCell {
1502                    coverage: 0.75,
1503                    hit_count: 8,
1504                },
1505            ],
1506        ];
1507
1508        let png = PngHeatmap::new(200, 200)
1509            .with_legend()
1510            .with_palette(ColorPalette::magma())
1511            .export(&cells)
1512            .unwrap();
1513
1514        assert!(!png.is_empty());
1515        assert_eq!(&png[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
1516    }
1517
1518    #[test]
1519    fn h0_png_11_builder_pattern() {
1520        let heatmap = PngHeatmap::new(800, 600)
1521            .with_palette(ColorPalette::heat())
1522            .with_legend()
1523            .with_gap_highlighting()
1524            .with_borders(false)
1525            .with_title("Test Heatmap");
1526
1527        // Verify settings applied (indirectly through export working)
1528        let cells = vec![vec![CoverageCell {
1529            coverage: 0.5,
1530            hit_count: 5,
1531        }]];
1532        let png = heatmap.export(&cells).unwrap();
1533        assert!(!png.is_empty());
1534    }
1535
1536    #[test]
1537    fn h0_png_12_export_to_file() {
1538        let cells = vec![
1539            vec![
1540                CoverageCell {
1541                    coverage: 0.0,
1542                    hit_count: 0,
1543                },
1544                CoverageCell {
1545                    coverage: 0.5,
1546                    hit_count: 5,
1547                },
1548                CoverageCell {
1549                    coverage: 1.0,
1550                    hit_count: 10,
1551                },
1552            ];
1553            3
1554        ];
1555
1556        let temp_dir = std::env::temp_dir();
1557        let path = temp_dir.join("test_heatmap.png");
1558
1559        PngHeatmap::new(300, 300)
1560            .with_gap_highlighting()
1561            .export_to_file(&cells, &path)
1562            .unwrap();
1563
1564        // Verify file exists and is valid PNG
1565        let bytes = std::fs::read(&path).unwrap();
1566        assert_eq!(&bytes[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
1567
1568        // Cleanup
1569        std::fs::remove_file(&path).ok();
1570    }
1571
1572    #[test]
1573    fn h0_png_13_default() {
1574        let heatmap = PngHeatmap::default();
1575        let cells = vec![vec![CoverageCell {
1576            coverage: 0.5,
1577            hit_count: 5,
1578        }]];
1579        let png = heatmap.export(&cells).unwrap();
1580        assert!(!png.is_empty());
1581    }
1582
1583    // =========================================================================
1584    // Title/Metadata Text Rendering Tests (H₀-TXT-XX)
1585    // =========================================================================
1586
1587    #[test]
1588    fn h0_txt_01_title_renders() {
1589        let cells = vec![
1590            vec![
1591                CoverageCell {
1592                    coverage: 0.5,
1593                    hit_count: 5,
1594                };
1595                5
1596            ];
1597            5
1598        ];
1599
1600        let png = PngHeatmap::new(400, 300)
1601            .with_title("Test Coverage")
1602            .export(&cells)
1603            .unwrap();
1604
1605        assert!(!png.is_empty());
1606        assert_eq!(&png[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
1607    }
1608
1609    #[test]
1610    fn h0_txt_02_title_with_legend() {
1611        let cells = vec![
1612            vec![
1613                CoverageCell {
1614                    coverage: 1.0,
1615                    hit_count: 10,
1616                };
1617                3
1618            ];
1619            3
1620        ];
1621
1622        let png = PngHeatmap::new(400, 300)
1623            .with_title("Coverage Heatmap")
1624            .with_legend()
1625            .export(&cells)
1626            .unwrap();
1627
1628        assert!(!png.is_empty());
1629    }
1630
1631    #[test]
1632    fn h0_txt_03_bitmap_font_basic() {
1633        // Test that bitmap font renders without panics
1634        let font = BitmapFont::default();
1635        let glyph = font.glyph('A');
1636        assert!(!glyph.is_empty());
1637    }
1638
1639    #[test]
1640    fn h0_txt_04_bitmap_font_digits() {
1641        let font = BitmapFont::default();
1642        for c in '0'..='9' {
1643            let glyph = font.glyph(c);
1644            assert!(!glyph.is_empty(), "Digit {} should have a glyph", c);
1645        }
1646    }
1647
1648    #[test]
1649    fn h0_txt_05_bitmap_font_text_width() {
1650        let font = BitmapFont::default();
1651        let width = font.text_width("Hello");
1652        assert!(width > 0);
1653        assert_eq!(
1654            width,
1655            5 * (font.char_width() + font.spacing()) - font.spacing()
1656        );
1657    }
1658
1659    #[test]
1660    fn h0_txt_06_metadata_subtitle() {
1661        let cells = vec![
1662            vec![
1663                CoverageCell {
1664                    coverage: 0.75,
1665                    hit_count: 8,
1666                };
1667                4
1668            ];
1669            4
1670        ];
1671
1672        let png = PngHeatmap::new(500, 400)
1673            .with_title("Main Title")
1674            .with_subtitle("85% coverage")
1675            .export(&cells)
1676            .unwrap();
1677
1678        assert!(!png.is_empty());
1679    }
1680
1681    #[test]
1682    fn h0_txt_07_empty_title() {
1683        let cells = vec![vec![CoverageCell {
1684            coverage: 0.5,
1685            hit_count: 5,
1686        }]];
1687
1688        // Empty title should not cause issues
1689        let png = PngHeatmap::new(200, 200)
1690            .with_title("")
1691            .export(&cells)
1692            .unwrap();
1693
1694        assert!(!png.is_empty());
1695    }
1696
1697    #[test]
1698    fn h0_txt_08_special_characters() {
1699        let font = BitmapFont::default();
1700        // Should return empty glyph for unknown chars
1701        let glyph = font.glyph('€');
1702        assert!(glyph.is_empty() || glyph.iter().all(|&b| !b));
1703    }
1704
1705    // =========================================================================
1706    // Combined PNG Tests (H₀-CMB-XX)
1707    // =========================================================================
1708
1709    #[test]
1710    fn h0_cmb_01_combined_heatmap() {
1711        use super::super::tracker::{
1712            CombinedCoverageReport, LineCoverageReport, PixelCoverageReport,
1713        };
1714
1715        let cells = vec![
1716            vec![
1717                CoverageCell {
1718                    coverage: 0.8,
1719                    hit_count: 8,
1720                };
1721                10
1722            ];
1723            10
1724        ];
1725
1726        let line_report = LineCoverageReport::new(0.90, 1.0, 0.80, 22, 20);
1727        let pixel_report = PixelCoverageReport {
1728            overall_coverage: 0.85,
1729            covered_cells: 85,
1730            total_cells: 100,
1731            ..Default::default()
1732        };
1733        let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
1734
1735        let png = PngHeatmap::new(600, 500)
1736            .with_title("Combined Coverage")
1737            .with_legend()
1738            .with_combined_stats(&combined)
1739            .export(&cells)
1740            .unwrap();
1741
1742        assert!(!png.is_empty());
1743    }
1744
1745    #[test]
1746    fn h0_cmb_02_stats_panel_height() {
1747        // Stats panel should add extra height
1748        use super::super::tracker::{
1749            CombinedCoverageReport, LineCoverageReport, PixelCoverageReport,
1750        };
1751
1752        let line_report = LineCoverageReport::new(0.90, 1.0, 0.80, 22, 20);
1753        let pixel_report = PixelCoverageReport::default();
1754        let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
1755
1756        let heatmap = PngHeatmap::new(400, 300).with_combined_stats(&combined);
1757
1758        // The stats panel should be stored
1759        assert!(heatmap.stats_panel.is_some());
1760    }
1761
1762    // =========================================================================
1763    // Visual Regression Tests (H₀-VIS-XX)
1764    // =========================================================================
1765
1766    #[test]
1767    fn h0_vis_01_deterministic_output() {
1768        use super::visual_regression::*;
1769
1770        // Same input should produce identical output
1771        let cells = reference_gradient_cells(8, 10);
1772
1773        let png1 = PngHeatmap::new(400, 300)
1774            .with_palette(ColorPalette::viridis())
1775            .export(&cells)
1776            .unwrap();
1777
1778        let png2 = PngHeatmap::new(400, 300)
1779            .with_palette(ColorPalette::viridis())
1780            .export(&cells)
1781            .unwrap();
1782
1783        // Byte-for-byte identical
1784        assert_eq!(png1.len(), png2.len());
1785        assert_eq!(compute_checksum(&png1), compute_checksum(&png2));
1786    }
1787
1788    #[test]
1789    fn h0_vis_02_compare_identical_images() {
1790        use super::visual_regression::*;
1791
1792        let cells = reference_uniform_cells(5, 5, 0.5);
1793        let png = PngHeatmap::new(200, 200).export(&cells).unwrap();
1794
1795        let result = compare_png_with_tolerance(&png, &png, 0).unwrap();
1796
1797        assert!(result.matches);
1798        assert_eq!(result.diff_count, 0);
1799        assert_eq!(result.max_diff, 0);
1800        assert!((result.diff_percentage - 0.0).abs() < 0.001);
1801    }
1802
1803    #[test]
1804    fn h0_vis_03_compare_different_palettes() {
1805        use super::visual_regression::*;
1806
1807        let cells = reference_gradient_cells(5, 5);
1808
1809        let png_viridis = PngHeatmap::new(200, 200)
1810            .with_palette(ColorPalette::viridis())
1811            .export(&cells)
1812            .unwrap();
1813
1814        let png_magma = PngHeatmap::new(200, 200)
1815            .with_palette(ColorPalette::magma())
1816            .export(&cells)
1817            .unwrap();
1818
1819        // Different palettes should produce different output
1820        let result = compare_png_with_tolerance(&png_viridis, &png_magma, 0).unwrap();
1821
1822        assert!(!result.matches || result.max_diff > 0);
1823    }
1824
1825    #[test]
1826    fn h0_vis_04_gap_highlighting_visible() {
1827        use super::visual_regression::*;
1828
1829        let cells = reference_gap_cells(8, 10);
1830
1831        let png_no_gaps = PngHeatmap::new(400, 300).export(&cells).unwrap();
1832
1833        let png_with_gaps = PngHeatmap::new(400, 300)
1834            .with_gap_highlighting()
1835            .export(&cells)
1836            .unwrap();
1837
1838        // Gap highlighting should produce different output
1839        let result = compare_png_with_tolerance(&png_no_gaps, &png_with_gaps, 0).unwrap();
1840
1841        // Should have some differences (the red gap borders)
1842        assert!(
1843            result.diff_count > 0,
1844            "Gap highlighting should produce visible differences"
1845        );
1846    }
1847
1848    #[test]
1849    fn h0_vis_05_legend_visible() {
1850        use super::visual_regression::*;
1851
1852        let cells = reference_gradient_cells(5, 5);
1853
1854        let png_no_legend = PngHeatmap::new(300, 250).export(&cells).unwrap();
1855
1856        let png_with_legend = PngHeatmap::new(300, 250)
1857            .with_legend()
1858            .export(&cells)
1859            .unwrap();
1860
1861        // Legend should produce different output
1862        let result = compare_png_with_tolerance(&png_no_legend, &png_with_legend, 0).unwrap();
1863
1864        assert!(
1865            result.diff_count > 0,
1866            "Legend should produce visible differences"
1867        );
1868    }
1869
1870    #[test]
1871    fn h0_vis_06_title_visible() {
1872        use super::visual_regression::*;
1873
1874        let cells = reference_uniform_cells(4, 4, 0.75);
1875
1876        let png_no_title = PngHeatmap::new(300, 200).export(&cells).unwrap();
1877
1878        let png_with_title = PngHeatmap::new(300, 200)
1879            .with_title("Test Title")
1880            .export(&cells)
1881            .unwrap();
1882
1883        // Title should produce different output
1884        let result = compare_png_with_tolerance(&png_no_title, &png_with_title, 0).unwrap();
1885
1886        assert!(
1887            result.diff_count > 0,
1888            "Title should produce visible differences"
1889        );
1890    }
1891
1892    #[test]
1893    fn h0_vis_07_reference_viridis_gradient() {
1894        use super::visual_regression::*;
1895
1896        // Generate reference gradient with Viridis palette
1897        let cells = reference_gradient_cells(10, 15);
1898        let png = PngHeatmap::new(800, 600)
1899            .with_palette(ColorPalette::viridis())
1900            .with_legend()
1901            .with_margin(40)
1902            .export(&cells)
1903            .unwrap();
1904
1905        // Store checksum as reference (captured from known-good output)
1906        let checksum = compute_checksum(&png);
1907
1908        // Verify we get a valid PNG
1909        assert!(!png.is_empty());
1910        assert_eq!(&png[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
1911
1912        // Re-generate and verify determinism
1913        let png2 = PngHeatmap::new(800, 600)
1914            .with_palette(ColorPalette::viridis())
1915            .with_legend()
1916            .with_margin(40)
1917            .export(&cells)
1918            .unwrap();
1919
1920        assert_eq!(
1921            compute_checksum(&png2),
1922            checksum,
1923            "Output should be deterministic"
1924        );
1925    }
1926
1927    #[test]
1928    fn h0_vis_08_reference_magma_gaps() {
1929        use super::visual_regression::*;
1930
1931        // Generate reference with gaps and Magma palette
1932        let cells = reference_gap_cells(8, 12);
1933        let png = PngHeatmap::new(600, 400)
1934            .with_palette(ColorPalette::magma())
1935            .with_gap_highlighting()
1936            .with_legend()
1937            .export(&cells)
1938            .unwrap();
1939
1940        let checksum = compute_checksum(&png);
1941
1942        // Verify determinism
1943        let png2 = PngHeatmap::new(600, 400)
1944            .with_palette(ColorPalette::magma())
1945            .with_gap_highlighting()
1946            .with_legend()
1947            .export(&cells)
1948            .unwrap();
1949
1950        assert_eq!(
1951            compute_checksum(&png2),
1952            checksum,
1953            "Magma gap output should be deterministic"
1954        );
1955    }
1956
1957    #[test]
1958    fn h0_vis_09_reference_heat_with_title() {
1959        use super::visual_regression::*;
1960
1961        // Generate reference with Heat palette and title
1962        let cells = reference_uniform_cells(6, 8, 0.65);
1963        let png = PngHeatmap::new(500, 400)
1964            .with_palette(ColorPalette::heat())
1965            .with_title("Heat Coverage")
1966            .with_subtitle("Reference Test")
1967            .with_legend()
1968            .export(&cells)
1969            .unwrap();
1970
1971        let checksum = compute_checksum(&png);
1972
1973        // Verify determinism
1974        let png2 = PngHeatmap::new(500, 400)
1975            .with_palette(ColorPalette::heat())
1976            .with_title("Heat Coverage")
1977            .with_subtitle("Reference Test")
1978            .with_legend()
1979            .export(&cells)
1980            .unwrap();
1981
1982        assert_eq!(
1983            compute_checksum(&png2),
1984            checksum,
1985            "Heat title output should be deterministic"
1986        );
1987    }
1988
1989    #[test]
1990    fn h0_vis_10_tolerance_comparison() {
1991        use super::visual_regression::*;
1992
1993        let cells = reference_gradient_cells(5, 5);
1994        let png = PngHeatmap::new(200, 200).export(&cells).unwrap();
1995
1996        // Exact match with 0 tolerance
1997        let result0 = compare_png_with_tolerance(&png, &png, 0).unwrap();
1998        assert!(result0.matches);
1999        assert_eq!(result0.diff_count, 0);
2000
2001        // Also matches with higher tolerance
2002        let result10 = compare_png_with_tolerance(&png, &png, 10).unwrap();
2003        assert!(result10.matches);
2004        assert_eq!(result10.diff_count, 0);
2005    }
2006
2007    #[test]
2008    fn h0_vis_11_combined_stats_determinism() {
2009        use super::super::tracker::{
2010            CombinedCoverageReport, LineCoverageReport, PixelCoverageReport,
2011        };
2012        use super::visual_regression::*;
2013
2014        let cells = reference_gradient_cells(8, 10);
2015
2016        let line_report = LineCoverageReport::new(0.85, 0.95, 0.90, 20, 17);
2017        let pixel_report = PixelCoverageReport {
2018            overall_coverage: 0.80,
2019            covered_cells: 64,
2020            total_cells: 80,
2021            ..Default::default()
2022        };
2023        let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
2024
2025        let png1 = PngHeatmap::new(700, 600)
2026            .with_palette(ColorPalette::viridis())
2027            .with_title("Combined Report")
2028            .with_legend()
2029            .with_gap_highlighting()
2030            .with_combined_stats(&combined)
2031            .export(&cells)
2032            .unwrap();
2033
2034        let checksum1 = compute_checksum(&png1);
2035
2036        // Re-create with same parameters
2037        let line_report2 = LineCoverageReport::new(0.85, 0.95, 0.90, 20, 17);
2038        let pixel_report2 = PixelCoverageReport {
2039            overall_coverage: 0.80,
2040            covered_cells: 64,
2041            total_cells: 80,
2042            ..Default::default()
2043        };
2044        let combined2 = CombinedCoverageReport::from_parts(line_report2, pixel_report2);
2045
2046        let png2 = PngHeatmap::new(700, 600)
2047            .with_palette(ColorPalette::viridis())
2048            .with_title("Combined Report")
2049            .with_legend()
2050            .with_gap_highlighting()
2051            .with_combined_stats(&combined2)
2052            .export(&cells)
2053            .unwrap();
2054
2055        assert_eq!(
2056            compute_checksum(&png2),
2057            checksum1,
2058            "Combined stats output should be deterministic"
2059        );
2060    }
2061
2062    #[test]
2063    fn h0_vis_12_dimension_mismatch() {
2064        use super::visual_regression::*;
2065
2066        let cells_small = reference_uniform_cells(3, 3, 0.5);
2067        let cells_large = reference_uniform_cells(5, 5, 0.5);
2068
2069        let png_small = PngHeatmap::new(100, 100).export(&cells_small).unwrap();
2070        let png_large = PngHeatmap::new(200, 200).export(&cells_large).unwrap();
2071
2072        // Different dimensions should fail comparison
2073        let result = compare_png_with_tolerance(&png_small, &png_large, 255).unwrap();
2074
2075        assert!(!result.matches, "Different dimensions should not match");
2076        assert_eq!(result.diff_percentage, 100.0);
2077    }
2078
2079    // =========================================================================
2080    // Additional Coverage Tests (H₀-COV-XX)
2081    // =========================================================================
2082
2083    #[test]
2084    fn h0_cov_01_terminal_from_tracker() {
2085        // Test TerminalHeatmap::from_tracker
2086        let tracker = super::super::tracker::PixelCoverageTracker::new(100, 100, 5, 5);
2087        let heatmap = TerminalHeatmap::from_tracker(&tracker);
2088        let rendered = heatmap.render();
2089        // Should render 5 rows
2090        assert_eq!(rendered.lines().count(), 5);
2091    }
2092
2093    #[test]
2094    fn h0_cov_02_terminal_with_palette() {
2095        let cells = vec![vec![0.5, 1.0], vec![0.0, 0.25]];
2096        let heatmap = TerminalHeatmap::from_values(cells)
2097            .with_palette(ColorPalette::traffic_light())
2098            .without_color();
2099        let rendered = heatmap.render();
2100        assert!(rendered.contains('▒')); // 50% coverage
2101        assert!(rendered.contains('█')); // 100% coverage
2102    }
2103
2104    #[test]
2105    fn h0_cov_03_terminal_render_with_color() {
2106        let cells = vec![vec![0.0, 0.5, 1.0]];
2107        let heatmap = TerminalHeatmap::from_values(cells);
2108        // use_color is true by default
2109        let rendered = heatmap.render();
2110        // Should contain ANSI escape sequences
2111        assert!(rendered.contains("\x1b[38;2;"));
2112        assert!(rendered.contains("\x1b[0m"));
2113    }
2114
2115    #[test]
2116    fn h0_cov_04_terminal_border_with_color() {
2117        let cells = vec![vec![0.5, 1.0]];
2118        let heatmap = TerminalHeatmap::from_values(cells);
2119        let rendered = heatmap.render_with_border();
2120        // Should contain border chars and ANSI sequences
2121        assert!(rendered.contains('┌'));
2122        assert!(rendered.contains("\x1b[38;2;"));
2123    }
2124
2125    #[test]
2126    fn h0_cov_05_terminal_legend_with_color() {
2127        let cells = vec![vec![1.0]];
2128        let heatmap = TerminalHeatmap::from_values(cells);
2129        let legend = heatmap.legend();
2130        // Should contain ANSI escape sequences in legend
2131        assert!(legend.contains("\x1b[38;2;"));
2132        assert!(legend.contains("Legend:"));
2133    }
2134
2135    #[test]
2136    fn h0_cov_06_terminal_empty_cells_border() {
2137        let cells: Vec<Vec<f32>> = vec![];
2138        let heatmap = TerminalHeatmap::from_values(cells).without_color();
2139        let rendered = heatmap.render_with_border();
2140        // Should still render borders with width 0
2141        assert!(rendered.contains('┌'));
2142        assert!(rendered.contains('└'));
2143    }
2144
2145    #[test]
2146    fn h0_cov_07_png_with_margin() {
2147        let cells = vec![vec![CoverageCell {
2148            coverage: 0.5,
2149            hit_count: 5,
2150        }]];
2151        let png = PngHeatmap::new(200, 200)
2152            .with_margin(60)
2153            .export(&cells)
2154            .unwrap();
2155        assert!(!png.is_empty());
2156        assert_eq!(&png[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
2157    }
2158
2159    #[test]
2160    fn h0_cov_08_png_with_background() {
2161        let cells = vec![vec![CoverageCell {
2162            coverage: 0.5,
2163            hit_count: 5,
2164        }]];
2165        let png = PngHeatmap::new(200, 200)
2166            .with_background(Rgb::new(0, 0, 0)) // Black background
2167            .export(&cells)
2168            .unwrap();
2169        assert!(!png.is_empty());
2170    }
2171
2172    #[test]
2173    fn h0_cov_09_png_with_border_color() {
2174        let cells = vec![
2175            vec![
2176                CoverageCell {
2177                    coverage: 0.5,
2178                    hit_count: 5,
2179                };
2180                3
2181            ];
2182            3
2183        ];
2184        let png = PngHeatmap::new(200, 200)
2185            .with_border_color(Rgb::new(255, 0, 0)) // Red borders
2186            .export(&cells)
2187            .unwrap();
2188        assert!(!png.is_empty());
2189    }
2190
2191    #[test]
2192    fn h0_cov_10_bitmap_font_dimensions() {
2193        let font = BitmapFont::default();
2194        assert_eq!(font.char_width(), 5);
2195        assert_eq!(font.char_height(), 7);
2196        assert_eq!(font.spacing(), 1);
2197    }
2198
2199    #[test]
2200    fn h0_cov_11_bitmap_font_empty_text_width() {
2201        let font = BitmapFont::default();
2202        assert_eq!(font.text_width(""), 0);
2203    }
2204
2205    #[test]
2206    fn h0_cov_12_bitmap_font_single_char_width() {
2207        let font = BitmapFont::default();
2208        let width = font.text_width("A");
2209        assert_eq!(width, 5); // Just char_width, no spacing
2210    }
2211
2212    #[test]
2213    fn h0_cov_13_bitmap_font_punctuation() {
2214        let font = BitmapFont::default();
2215        // Test all punctuation characters
2216        let chars = [
2217            '.', ',', ':', '-', '_', '/', '%', '(', ')', '=', '+', '*', '!', '?', ' ',
2218        ];
2219        for c in chars {
2220            let glyph = font.glyph(c);
2221            assert_eq!(glyph.len(), 35, "Glyph for '{}' should have 35 bits", c);
2222        }
2223    }
2224
2225    #[test]
2226    fn h0_cov_14_bitmap_font_lowercase_to_uppercase() {
2227        let font = BitmapFont::default();
2228        // Lowercase should map to uppercase
2229        let upper = font.glyph('A');
2230        let lower = font.glyph('a');
2231        assert_eq!(upper, lower, "Lowercase should map to uppercase");
2232    }
2233
2234    #[test]
2235    fn h0_cov_15_bitmap_font_all_uppercase() {
2236        let font = BitmapFont::default();
2237        for c in 'A'..='Z' {
2238            let glyph = font.glyph(c);
2239            // Each glyph should have some pixels set (not all false)
2240            assert!(
2241                glyph.iter().any(|&b| b),
2242                "Glyph for '{}' should have some pixels",
2243                c
2244            );
2245        }
2246    }
2247
2248    #[test]
2249    fn h0_cov_16_rgb_lerp_clamping() {
2250        let black = Rgb::new(0, 0, 0);
2251        let white = Rgb::new(255, 255, 255);
2252
2253        // Test clamping at negative values
2254        let below = Rgb::lerp(black, white, -1.0);
2255        assert_eq!(below, black);
2256
2257        // Test clamping above 1.0
2258        let above = Rgb::lerp(black, white, 2.0);
2259        assert_eq!(above, white);
2260    }
2261
2262    #[test]
2263    fn h0_cov_17_color_palette_default() {
2264        let default = ColorPalette::default();
2265        let viridis = ColorPalette::viridis();
2266        assert_eq!(default.zero, viridis.zero);
2267        assert_eq!(default.full, viridis.full);
2268    }
2269
2270    #[test]
2271    fn h0_cov_18_svg_with_palette() {
2272        let cells = vec![vec![CoverageCell {
2273            coverage: 0.5,
2274            hit_count: 5,
2275        }]];
2276        let svg = SvgHeatmap::new(100, 100)
2277            .with_palette(ColorPalette::magma())
2278            .export(&cells);
2279        assert!(svg.contains("<svg"));
2280        assert!(svg.contains("</svg>"));
2281    }
2282
2283    #[test]
2284    fn h0_cov_19_reference_gap_cells_small() {
2285        use super::visual_regression::*;
2286        // Test with small grid that won't have gaps at division points
2287        let cells = reference_gap_cells(2, 2);
2288        assert_eq!(cells.len(), 2);
2289        assert_eq!(cells[0].len(), 2);
2290    }
2291
2292    #[test]
2293    fn h0_cov_20_reference_gap_cells_medium() {
2294        use super::visual_regression::*;
2295        // Test with grid large enough for first gap but not second
2296        let cells = reference_gap_cells(3, 3);
2297        // rows/2 = 1, cols/2 = 1 -> gap at (1,1)
2298        assert_eq!(cells[1][1].coverage, 0.0);
2299        assert_eq!(cells[1][1].hit_count, 0);
2300    }
2301
2302    #[test]
2303    fn h0_cov_21_stats_panel_fields() {
2304        let panel = StatsPanel {
2305            line_coverage: 85.5,
2306            pixel_coverage: 90.2,
2307            overall_score: 87.85,
2308            line_details: (17, 20),
2309            pixel_details: (45, 50),
2310            meets_threshold: true,
2311        };
2312        assert!((panel.line_coverage - 85.5).abs() < 0.01);
2313        assert!((panel.pixel_coverage - 90.2).abs() < 0.01);
2314        assert!((panel.overall_score - 87.85).abs() < 0.01);
2315        assert_eq!(panel.line_details, (17, 20));
2316        assert_eq!(panel.pixel_details, (45, 50));
2317        assert!(panel.meets_threshold);
2318    }
2319
2320    #[test]
2321    fn h0_cov_22_stats_panel_fail_threshold() {
2322        use super::super::tracker::{
2323            CombinedCoverageReport, LineCoverageReport, PixelCoverageReport,
2324        };
2325
2326        let cells = vec![
2327            vec![
2328                CoverageCell {
2329                    coverage: 0.3,
2330                    hit_count: 3,
2331                };
2332                5
2333            ];
2334            5
2335        ];
2336
2337        // Create report that fails threshold
2338        let line_report = LineCoverageReport::new(0.5, 0.5, 0.5, 10, 5);
2339        let pixel_report = PixelCoverageReport {
2340            overall_coverage: 0.3,
2341            covered_cells: 15,
2342            total_cells: 50,
2343            ..Default::default()
2344        };
2345        let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
2346
2347        let png = PngHeatmap::new(400, 400)
2348            .with_combined_stats(&combined)
2349            .export(&cells)
2350            .unwrap();
2351
2352        assert!(!png.is_empty());
2353    }
2354
2355    #[test]
2356    fn h0_cov_23_empty_subtitle() {
2357        let cells = vec![vec![CoverageCell {
2358            coverage: 0.5,
2359            hit_count: 5,
2360        }]];
2361        let png = PngHeatmap::new(200, 200)
2362            .with_subtitle("")
2363            .export(&cells)
2364            .unwrap();
2365        assert!(!png.is_empty());
2366    }
2367
2368    #[test]
2369    fn h0_cov_24_title_and_subtitle() {
2370        let cells = vec![vec![CoverageCell {
2371            coverage: 0.5,
2372            hit_count: 5,
2373        }]];
2374        let png = PngHeatmap::new(400, 300)
2375            .with_title("Title")
2376            .with_subtitle("Subtitle")
2377            .export(&cells)
2378            .unwrap();
2379        assert!(!png.is_empty());
2380    }
2381
2382    #[test]
2383    fn h0_cov_25_coverage_boundaries() {
2384        // Test exact boundary values for color_for_coverage
2385        let palette = ColorPalette::viridis();
2386
2387        // Negative coverage
2388        assert_eq!(palette.color_for_coverage(-0.1), palette.zero);
2389
2390        // Exactly 0.25
2391        assert_eq!(palette.color_for_coverage(0.25), palette.low);
2392
2393        // Exactly 0.50
2394        assert_eq!(palette.color_for_coverage(0.50), palette.medium);
2395
2396        // Exactly 0.75
2397        assert_eq!(palette.color_for_coverage(0.75), palette.high);
2398
2399        // Above 0.75
2400        assert_eq!(palette.color_for_coverage(0.76), palette.full);
2401    }
2402
2403    #[test]
2404    fn h0_cov_26_coverage_to_char_boundaries() {
2405        // Test exact boundary values
2406        assert_eq!(TerminalHeatmap::coverage_to_char(-0.1), ' ');
2407        assert_eq!(TerminalHeatmap::coverage_to_char(0.25), '░');
2408        assert_eq!(TerminalHeatmap::coverage_to_char(0.26), '▒');
2409        assert_eq!(TerminalHeatmap::coverage_to_char(0.50), '▒');
2410        assert_eq!(TerminalHeatmap::coverage_to_char(0.51), '▓');
2411        assert_eq!(TerminalHeatmap::coverage_to_char(0.75), '▓');
2412        assert_eq!(TerminalHeatmap::coverage_to_char(0.76), '█');
2413    }
2414
2415    #[test]
2416    fn h0_cov_27_interpolate_mid_segment() {
2417        let palette = ColorPalette::viridis();
2418
2419        // Test interpolation within a segment (not at boundaries)
2420        let c = palette.interpolate(0.125); // Middle of 0-0.25 segment
2421                                            // Should be between zero and low
2422        assert_ne!(c, palette.zero);
2423        assert_ne!(c, palette.low);
2424    }
2425
2426    #[test]
2427    fn h0_cov_28_reference_gradient_single_cell() {
2428        use super::visual_regression::*;
2429        // Single cell grid (edge case with max(1) divisor)
2430        let cells = reference_gradient_cells(1, 1);
2431        assert_eq!(cells.len(), 1);
2432        assert_eq!(cells[0].len(), 1);
2433        // Coverage should be 0.0 (row=0, col=0, divided by max(1)=1)
2434        assert!((cells[0][0].coverage - 0.0).abs() < 0.01);
2435    }
2436
2437    #[test]
2438    fn h0_cov_29_png_borders_disabled() {
2439        let cells = vec![
2440            vec![
2441                CoverageCell {
2442                    coverage: 0.5,
2443                    hit_count: 5,
2444                };
2445                3
2446            ];
2447            3
2448        ];
2449        let png = PngHeatmap::new(200, 200)
2450            .with_borders(false)
2451            .export(&cells)
2452            .unwrap();
2453        assert!(!png.is_empty());
2454    }
2455
2456    #[test]
2457    fn h0_cov_30_png_all_options() {
2458        use super::super::tracker::{
2459            CombinedCoverageReport, LineCoverageReport, PixelCoverageReport,
2460        };
2461
2462        let cells = vec![
2463            vec![
2464                CoverageCell {
2465                    coverage: 0.0,
2466                    hit_count: 0,
2467                },
2468                CoverageCell {
2469                    coverage: 0.5,
2470                    hit_count: 5,
2471                },
2472            ],
2473            vec![
2474                CoverageCell {
2475                    coverage: 1.0,
2476                    hit_count: 10,
2477                },
2478                CoverageCell {
2479                    coverage: 0.0,
2480                    hit_count: 0,
2481                },
2482            ],
2483        ];
2484
2485        let line_report = LineCoverageReport::new(0.9, 0.95, 0.85, 20, 18);
2486        let pixel_report = PixelCoverageReport {
2487            overall_coverage: 0.5,
2488            covered_cells: 2,
2489            total_cells: 4,
2490            ..Default::default()
2491        };
2492        let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
2493
2494        let png = PngHeatmap::new(600, 500)
2495            .with_palette(ColorPalette::traffic_light())
2496            .with_title("Full Options Test")
2497            .with_subtitle("All features enabled")
2498            .with_legend()
2499            .with_gap_highlighting()
2500            .with_borders(true)
2501            .with_margin(50)
2502            .with_background(Rgb::new(240, 240, 240))
2503            .with_border_color(Rgb::new(100, 100, 100))
2504            .with_combined_stats(&combined)
2505            .export(&cells)
2506            .unwrap();
2507
2508        assert!(!png.is_empty());
2509        assert_eq!(&png[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
2510    }
2511
2512    #[test]
2513    fn h0_cov_31_rgb_new() {
2514        let color = Rgb::new(128, 64, 32);
2515        assert_eq!(color.r, 128);
2516        assert_eq!(color.g, 64);
2517        assert_eq!(color.b, 32);
2518    }
2519
2520    #[test]
2521    fn h0_cov_32_comparison_result_fields() {
2522        use super::visual_regression::*;
2523
2524        let cells = reference_uniform_cells(5, 5, 0.5);
2525        let png = PngHeatmap::new(200, 200).export(&cells).unwrap();
2526        let result = compare_png_with_tolerance(&png, &png, 0).unwrap();
2527
2528        // Verify all fields are accessible
2529        assert!(result.matches);
2530        assert_eq!(result.diff_count, 0);
2531        assert_eq!(result.max_diff, 0);
2532        assert!((result.diff_percentage - 0.0).abs() < 0.001);
2533        assert!(result.total_pixels > 0);
2534    }
2535
2536    #[test]
2537    fn h0_cov_33_checksum_determinism() {
2538        use super::visual_regression::*;
2539
2540        let data1 = vec![1, 2, 3, 4, 5];
2541        let data2 = vec![1, 2, 3, 4, 5];
2542        let data3 = vec![5, 4, 3, 2, 1];
2543
2544        assert_eq!(compute_checksum(&data1), compute_checksum(&data2));
2545        assert_ne!(compute_checksum(&data1), compute_checksum(&data3));
2546    }
2547
2548    #[test]
2549    fn h0_cov_34_svg_multiple_cells() {
2550        let cells = vec![
2551            vec![
2552                CoverageCell {
2553                    coverage: 0.0,
2554                    hit_count: 0,
2555                },
2556                CoverageCell {
2557                    coverage: 0.5,
2558                    hit_count: 5,
2559                },
2560                CoverageCell {
2561                    coverage: 1.0,
2562                    hit_count: 10,
2563                },
2564            ],
2565            vec![
2566                CoverageCell {
2567                    coverage: 0.25,
2568                    hit_count: 2,
2569                },
2570                CoverageCell {
2571                    coverage: 0.75,
2572                    hit_count: 7,
2573                },
2574                CoverageCell {
2575                    coverage: 0.5,
2576                    hit_count: 5,
2577                },
2578            ],
2579        ];
2580
2581        let svg = SvgHeatmap::new(300, 200).export(&cells);
2582
2583        // Should have 6 rect elements (2 rows x 3 cols)
2584        let rect_count = svg.matches("<rect").count();
2585        assert_eq!(rect_count, 6);
2586    }
2587
2588    #[test]
2589    fn h0_cov_35_bitmap_font_render_bounds() {
2590        use image::{ImageBuffer, RgbImage};
2591
2592        let font = BitmapFont::default();
2593        let mut img: RgbImage = ImageBuffer::new(10, 10);
2594
2595        // Try to render text that would overflow bounds
2596        font.render_text(&mut img, "HELLO WORLD TEST", 5, 5, Rgb::new(0, 0, 0));
2597
2598        // Should not panic - pixels outside bounds are simply skipped
2599    }
2600
2601    #[test]
2602    fn h0_cov_36_interpolate_at_segment_boundaries() {
2603        let palette = ColorPalette::viridis();
2604
2605        // Test values just below boundaries
2606        let c1 = palette.interpolate(0.249);
2607        let c2 = palette.interpolate(0.251);
2608        // These should be in different segments, producing different interpolations
2609        assert!(c1.r != c2.r || c1.g != c2.g || c1.b != c2.b);
2610    }
2611
2612    #[test]
2613    fn h0_cov_37_heatmap_renderer_trait() {
2614        // Test that HeatmapRenderer trait is properly defined
2615        struct TestRenderer;
2616        impl HeatmapRenderer for TestRenderer {
2617            fn render(&self, cells: &[Vec<CoverageCell>]) -> String {
2618                format!("{}x{}", cells.len(), cells.first().map_or(0, Vec::len))
2619            }
2620        }
2621
2622        let cells = vec![
2623            vec![
2624                CoverageCell {
2625                    coverage: 1.0,
2626                    hit_count: 10,
2627                };
2628                3
2629            ];
2630            2
2631        ];
2632        let renderer = TestRenderer;
2633        assert_eq!(renderer.render(&cells), "2x3");
2634    }
2635
2636    #[test]
2637    fn h0_cov_38_terminal_multiple_rows() {
2638        let cells = vec![
2639            vec![0.0, 0.1, 0.2],
2640            vec![0.3, 0.4, 0.5],
2641            vec![0.6, 0.7, 0.8],
2642            vec![0.9, 1.0, 0.0],
2643        ];
2644        let heatmap = TerminalHeatmap::from_values(cells).without_color();
2645        let rendered = heatmap.render();
2646
2647        // Should have 4 lines
2648        assert_eq!(rendered.lines().count(), 4);
2649
2650        // Each line should have 3 characters
2651        for line in rendered.lines() {
2652            assert_eq!(line.chars().count(), 3);
2653        }
2654    }
2655}