revue/testing/
visual.rs

1//! Visual regression testing for TUI applications
2//!
3//! Provides comprehensive visual testing including color, style, and layout comparison.
4//! Unlike simple text snapshots, visual tests capture the full rendered appearance.
5//!
6//! # Features
7//!
8//! | Feature | Description |
9//! |---------|-------------|
10//! | **Full Render Capture** | Colors, styles, and text |
11//! | **Diff Visualization** | See exactly what changed |
12//! | **Threshold Testing** | Allow minor variations |
13//! | **CI Integration** | GitHub Actions, GitLab CI |
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use revue::testing::{VisualTest, VisualTestConfig};
19//!
20//! #[test]
21//! fn test_button_styles() {
22//!     let mut test = VisualTest::new("button_styles");
23//!
24//!     // Render your widget
25//!     let buffer = render_button();
26//!
27//!     // Compare against golden file
28//!     test.assert_matches(&buffer);
29//! }
30//! ```
31//!
32//! # Updating Golden Files
33//!
34//! ```bash
35//! # Update all visual tests
36//! REVUE_UPDATE_VISUALS=1 cargo test
37//!
38//! # Update specific test
39//! REVUE_UPDATE_VISUALS=1 cargo test test_button_styles
40//! ```
41
42use crate::render::{Buffer, Modifier};
43use crate::style::Color;
44use std::collections::HashMap;
45use std::fs;
46use std::path::{Path, PathBuf};
47
48// =============================================================================
49// Visual Test Configuration
50// =============================================================================
51
52/// Configuration for visual regression tests
53#[derive(Debug, Clone)]
54pub struct VisualTestConfig {
55    /// Base directory for golden files
56    pub golden_dir: PathBuf,
57    /// Whether to update golden files instead of comparing
58    pub update_mode: bool,
59    /// Tolerance for color differences (0-255)
60    pub color_tolerance: u8,
61    /// Whether to generate diff images
62    pub generate_diff: bool,
63    /// Whether to fail on missing golden files
64    pub fail_on_missing: bool,
65    /// Include style information (bold, italic, etc.)
66    pub include_styles: bool,
67    /// Include color information
68    pub include_colors: bool,
69}
70
71impl Default for VisualTestConfig {
72    fn default() -> Self {
73        let update_mode = std::env::var("REVUE_UPDATE_VISUALS")
74            .map(|v| v == "1" || v.to_lowercase() == "true")
75            .unwrap_or(false);
76
77        Self {
78            golden_dir: PathBuf::from("tests/golden"),
79            update_mode,
80            color_tolerance: 0,
81            generate_diff: true,
82            fail_on_missing: false,
83            include_styles: true,
84            include_colors: true,
85        }
86    }
87}
88
89impl VisualTestConfig {
90    /// Create config with custom golden directory
91    pub fn with_dir(dir: impl Into<PathBuf>) -> Self {
92        Self {
93            golden_dir: dir.into(),
94            ..Default::default()
95        }
96    }
97
98    /// Set color tolerance (0 = exact match, 255 = any color matches)
99    pub fn tolerance(mut self, tolerance: u8) -> Self {
100        self.color_tolerance = tolerance;
101        self
102    }
103
104    /// Enable or disable diff generation
105    pub fn generate_diff(mut self, enable: bool) -> Self {
106        self.generate_diff = enable;
107        self
108    }
109
110    /// Enable or disable style comparison
111    pub fn include_styles(mut self, enable: bool) -> Self {
112        self.include_styles = enable;
113        self
114    }
115
116    /// Enable or disable color comparison
117    pub fn include_colors(mut self, enable: bool) -> Self {
118        self.include_colors = enable;
119        self
120    }
121}
122
123// =============================================================================
124// Visual Test
125// =============================================================================
126
127/// Visual regression test instance
128pub struct VisualTest {
129    /// Test name (used for file naming)
130    name: String,
131    /// Configuration
132    config: VisualTestConfig,
133    /// Test group/category
134    group: Option<String>,
135}
136
137impl VisualTest {
138    /// Create a new visual test
139    pub fn new(name: impl Into<String>) -> Self {
140        Self {
141            name: name.into(),
142            config: VisualTestConfig::default(),
143            group: None,
144        }
145    }
146
147    /// Create with custom configuration
148    pub fn with_config(name: impl Into<String>, config: VisualTestConfig) -> Self {
149        Self {
150            name: name.into(),
151            config,
152            group: None,
153        }
154    }
155
156    /// Set test group (creates subdirectory)
157    pub fn group(mut self, group: impl Into<String>) -> Self {
158        self.group = Some(group.into());
159        self
160    }
161
162    /// Get the golden file path
163    fn golden_path(&self) -> PathBuf {
164        let mut path = self.config.golden_dir.clone();
165        if let Some(ref group) = self.group {
166            path = path.join(group);
167        }
168        path.join(format!("{}.golden", self.name))
169    }
170
171    /// Get the diff file path
172    fn diff_path(&self) -> PathBuf {
173        let mut path = self.config.golden_dir.clone();
174        if let Some(ref group) = self.group {
175            path = path.join(group);
176        }
177        path.join(format!("{}.diff", self.name))
178    }
179
180    /// Assert that buffer matches golden file
181    pub fn assert_matches(&self, buffer: &Buffer) -> VisualTestResult {
182        let actual = VisualCapture::from_buffer(buffer, &self.config);
183        let golden_path = self.golden_path();
184
185        // Ensure directory exists
186        if let Some(parent) = golden_path.parent() {
187            if !parent.exists() {
188                fs::create_dir_all(parent)
189                    .unwrap_or_else(|e| panic!("Failed to create golden directory: {}", e));
190            }
191        }
192
193        if self.config.update_mode {
194            // Update mode: save new golden file
195            actual
196                .save(&golden_path)
197                .unwrap_or_else(|e| panic!("Failed to save golden file: {}", e));
198            println!("Updated golden file: {}", self.name);
199            return VisualTestResult::Updated;
200        }
201
202        if !golden_path.exists() {
203            if self.config.fail_on_missing {
204                panic!("Golden file not found: {:?}", golden_path);
205            } else {
206                // Create new golden file
207                actual
208                    .save(&golden_path)
209                    .unwrap_or_else(|e| panic!("Failed to create golden file: {}", e));
210                println!("Created golden file: {}", self.name);
211                return VisualTestResult::Created;
212            }
213        }
214
215        // Load and compare
216        let expected = VisualCapture::load(&golden_path)
217            .unwrap_or_else(|e| panic!("Failed to load golden file: {}", e));
218
219        let diff = actual.diff(&expected, self.config.color_tolerance);
220
221        if diff.has_differences() {
222            // Generate diff file if enabled
223            if self.config.generate_diff {
224                let diff_content = diff.to_string();
225                let diff_path = self.diff_path();
226                fs::write(&diff_path, &diff_content)
227                    .unwrap_or_else(|e| panic!("Failed to write diff file: {}", e));
228            }
229
230            panic!(
231                "\nVisual regression detected in '{}'!\n\n\
232                 {}\n\n\
233                 To update golden files, run:\n\
234                 REVUE_UPDATE_VISUALS=1 cargo test\n",
235                self.name,
236                diff.summary()
237            );
238        }
239
240        VisualTestResult::Passed
241    }
242
243    /// Compare two buffers and return diff
244    pub fn compare(&self, actual: &Buffer, expected: &Buffer) -> VisualDiff {
245        let actual_capture = VisualCapture::from_buffer(actual, &self.config);
246        let expected_capture = VisualCapture::from_buffer(expected, &self.config);
247        actual_capture.diff(&expected_capture, self.config.color_tolerance)
248    }
249}
250
251/// Result of a visual test
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum VisualTestResult {
254    /// Test passed (matches golden file)
255    Passed,
256    /// Golden file was created
257    Created,
258    /// Golden file was updated
259    Updated,
260    /// Test failed (differences found)
261    Failed,
262}
263
264// =============================================================================
265// Visual Capture
266// =============================================================================
267
268/// Captured visual state of a buffer
269#[derive(Debug, Clone)]
270pub struct VisualCapture {
271    /// Width of capture
272    pub width: u16,
273    /// Height of capture
274    pub height: u16,
275    /// Cell data
276    cells: Vec<CapturedCell>,
277    /// Include styles
278    include_styles: bool,
279    /// Include colors
280    include_colors: bool,
281}
282
283/// Captured cell data
284#[derive(Debug, Clone, PartialEq)]
285pub struct CapturedCell {
286    /// Character
287    pub symbol: char,
288    /// Foreground color
289    pub fg: Option<Color>,
290    /// Background color
291    pub bg: Option<Color>,
292    /// Is bold
293    pub bold: bool,
294    /// Is italic
295    pub italic: bool,
296    /// Is underline
297    pub underline: bool,
298    /// Is dim
299    pub dim: bool,
300}
301
302impl Default for CapturedCell {
303    fn default() -> Self {
304        Self {
305            symbol: ' ',
306            fg: None,
307            bg: None,
308            bold: false,
309            italic: false,
310            underline: false,
311            dim: false,
312        }
313    }
314}
315
316impl CapturedCell {
317    /// Create from character only
318    pub fn from_char(ch: char) -> Self {
319        Self {
320            symbol: ch,
321            ..Default::default()
322        }
323    }
324
325    /// Compare with tolerance for colors
326    pub fn matches(
327        &self,
328        other: &Self,
329        tolerance: u8,
330        include_styles: bool,
331        include_colors: bool,
332    ) -> bool {
333        // Symbol must match
334        if self.symbol != other.symbol {
335            return false;
336        }
337
338        // Compare colors if enabled
339        if include_colors {
340            if !colors_match(&self.fg, &other.fg, tolerance) {
341                return false;
342            }
343            if !colors_match(&self.bg, &other.bg, tolerance) {
344                return false;
345            }
346        }
347
348        // Compare styles if enabled
349        if include_styles
350            && (self.bold != other.bold
351                || self.italic != other.italic
352                || self.underline != other.underline
353                || self.dim != other.dim)
354        {
355            return false;
356        }
357
358        true
359    }
360}
361
362/// Check if two colors match within tolerance
363fn colors_match(a: &Option<Color>, b: &Option<Color>, tolerance: u8) -> bool {
364    match (a, b) {
365        (None, None) => true,
366        (Some(_), None) | (None, Some(_)) => tolerance == 255,
367        (Some(c1), Some(c2)) => {
368            if tolerance == 0 {
369                c1 == c2
370            } else {
371                // Compare RGB components
372                let (r1, g1, b1) = color_to_rgb(c1);
373                let (r2, g2, b2) = color_to_rgb(c2);
374                let dr = (r1 as i16 - r2 as i16).unsigned_abs() as u8;
375                let dg = (g1 as i16 - g2 as i16).unsigned_abs() as u8;
376                let db = (b1 as i16 - b2 as i16).unsigned_abs() as u8;
377                dr <= tolerance && dg <= tolerance && db <= tolerance
378            }
379        }
380    }
381}
382
383/// Convert Color to RGB tuple
384fn color_to_rgb(color: &Color) -> (u8, u8, u8) {
385    (color.r, color.g, color.b)
386}
387
388impl VisualCapture {
389    /// Create from buffer
390    pub fn from_buffer(buffer: &Buffer, config: &VisualTestConfig) -> Self {
391        let width = buffer.width();
392        let height = buffer.height();
393        let mut cells = Vec::with_capacity((width * height) as usize);
394
395        for y in 0..height {
396            for x in 0..width {
397                let cell = if let Some(buf_cell) = buffer.get(x, y) {
398                    CapturedCell {
399                        symbol: buf_cell.symbol,
400                        fg: if config.include_colors {
401                            buf_cell.fg
402                        } else {
403                            None
404                        },
405                        bg: if config.include_colors {
406                            buf_cell.bg
407                        } else {
408                            None
409                        },
410                        bold: config.include_styles && buf_cell.modifier.contains(Modifier::BOLD),
411                        italic: config.include_styles
412                            && buf_cell.modifier.contains(Modifier::ITALIC),
413                        underline: config.include_styles
414                            && buf_cell.modifier.contains(Modifier::UNDERLINE),
415                        dim: config.include_styles && buf_cell.modifier.contains(Modifier::DIM),
416                    }
417                } else {
418                    CapturedCell::default()
419                };
420                cells.push(cell);
421            }
422        }
423
424        Self {
425            width,
426            height,
427            cells,
428            include_styles: config.include_styles,
429            include_colors: config.include_colors,
430        }
431    }
432
433    /// Get cell at position
434    pub fn get(&self, x: u16, y: u16) -> Option<&CapturedCell> {
435        if x < self.width && y < self.height {
436            let idx = (y * self.width + x) as usize;
437            self.cells.get(idx)
438        } else {
439            None
440        }
441    }
442
443    /// Compare with another capture
444    pub fn diff(&self, other: &Self, tolerance: u8) -> VisualDiff {
445        let mut differences = Vec::new();
446
447        // Check size mismatch
448        if self.width != other.width || self.height != other.height {
449            return VisualDiff {
450                size_mismatch: Some(((self.width, self.height), (other.width, other.height))),
451                differences,
452                actual_width: self.width,
453                actual_height: self.height,
454                expected_width: other.width,
455                expected_height: other.height,
456            };
457        }
458
459        // Compare cells
460        for y in 0..self.height {
461            for x in 0..self.width {
462                let actual = self.get(x, y).unwrap();
463                let expected = other.get(x, y).unwrap();
464
465                if !actual.matches(
466                    expected,
467                    tolerance,
468                    self.include_styles,
469                    self.include_colors,
470                ) {
471                    differences.push(CellDiff {
472                        x,
473                        y,
474                        actual: actual.clone(),
475                        expected: expected.clone(),
476                    });
477                }
478            }
479        }
480
481        VisualDiff {
482            size_mismatch: None,
483            differences,
484            actual_width: self.width,
485            actual_height: self.height,
486            expected_width: other.width,
487            expected_height: other.height,
488        }
489    }
490
491    /// Save to file
492    pub fn save(&self, path: &Path) -> std::io::Result<()> {
493        let content = self.serialize();
494        fs::write(path, content)
495    }
496
497    /// Load from file
498    pub fn load(path: &Path) -> std::io::Result<Self> {
499        let content = fs::read_to_string(path)?;
500        Self::deserialize(&content)
501    }
502
503    /// Serialize to string format
504    fn serialize(&self) -> String {
505        let mut output = String::new();
506
507        // Header
508        output.push_str(&format!(
509            "# Visual Golden File\n# Size: {}x{}\n# Styles: {}\n# Colors: {}\n\n",
510            self.width, self.height, self.include_styles, self.include_colors
511        ));
512
513        // Text layer
514        output.push_str("## Text\n");
515        for y in 0..self.height {
516            for x in 0..self.width {
517                if let Some(cell) = self.get(x, y) {
518                    output.push(cell.symbol);
519                } else {
520                    output.push(' ');
521                }
522            }
523            output.push('\n');
524        }
525
526        // Color layer (if included)
527        if self.include_colors {
528            output.push_str("\n## Colors\n");
529            for y in 0..self.height {
530                for x in 0..self.width {
531                    if let Some(cell) = self.get(x, y) {
532                        if let Some(fg) = &cell.fg {
533                            let (r, g, b) = color_to_rgb(fg);
534                            output.push_str(&format!(
535                                "{}:{},{}:#{:02x}{:02x}{:02x} ",
536                                x, y, "fg", r, g, b
537                            ));
538                        }
539                        if let Some(bg) = &cell.bg {
540                            let (r, g, b) = color_to_rgb(bg);
541                            output.push_str(&format!(
542                                "{}:{},{}:#{:02x}{:02x}{:02x} ",
543                                x, y, "bg", r, g, b
544                            ));
545                        }
546                    }
547                }
548            }
549            output.push('\n');
550        }
551
552        // Style layer (if included)
553        if self.include_styles {
554            output.push_str("\n## Styles\n");
555            for y in 0..self.height {
556                for x in 0..self.width {
557                    if let Some(cell) = self.get(x, y) {
558                        let mut styles = Vec::new();
559                        if cell.bold {
560                            styles.push("B");
561                        }
562                        if cell.italic {
563                            styles.push("I");
564                        }
565                        if cell.underline {
566                            styles.push("U");
567                        }
568                        if cell.dim {
569                            styles.push("D");
570                        }
571                        if !styles.is_empty() {
572                            output.push_str(&format!("{}:{}:{} ", x, y, styles.join("")));
573                        }
574                    }
575                }
576            }
577            output.push('\n');
578        }
579
580        output
581    }
582
583    /// Deserialize from string format
584    fn deserialize(content: &str) -> std::io::Result<Self> {
585        let mut width = 0u16;
586        let mut height = 0u16;
587        let mut include_styles = true;
588        let mut include_colors = true;
589        let mut cells = Vec::new();
590        let mut in_text = false;
591        let mut in_colors = false;
592        let mut in_styles = false;
593        let mut text_lines: Vec<String> = Vec::new();
594        let mut color_data: HashMap<(u16, u16, String), (u8, u8, u8)> = HashMap::new();
595        let mut style_data: HashMap<(u16, u16), (bool, bool, bool, bool)> = HashMap::new();
596
597        for line in content.lines() {
598            let line = line.trim_end();
599
600            // Parse header
601            if line.starts_with("# Size:") {
602                let parts: Vec<&str> = line.split_whitespace().collect();
603                if parts.len() >= 3 {
604                    let size_parts: Vec<&str> = parts[2].split('x').collect();
605                    if size_parts.len() == 2 {
606                        width = size_parts[0].parse().unwrap_or(0);
607                        height = size_parts[1].parse().unwrap_or(0);
608                    }
609                }
610                continue;
611            }
612            if line.starts_with("# Styles:") {
613                include_styles = line.contains("true");
614                continue;
615            }
616            if line.starts_with("# Colors:") {
617                include_colors = line.contains("true");
618                continue;
619            }
620
621            // Section markers
622            if line == "## Text" {
623                in_text = true;
624                in_colors = false;
625                in_styles = false;
626                continue;
627            }
628            if line == "## Colors" {
629                in_text = false;
630                in_colors = true;
631                in_styles = false;
632                continue;
633            }
634            if line == "## Styles" {
635                in_text = false;
636                in_colors = false;
637                in_styles = true;
638                continue;
639            }
640
641            if line.starts_with('#') || line.is_empty() {
642                continue;
643            }
644
645            // Parse sections
646            if in_text {
647                text_lines.push(line.to_string());
648            } else if in_colors {
649                for part in line.split_whitespace() {
650                    // Format: x:y,type:#rrggbb
651                    if let Some((coord, color)) = part.split_once(',') {
652                        if let Some((pos, hex)) = coord.split_once(':') {
653                            if let Some((kind, hex_val)) = color.split_once(':') {
654                                let x: u16 = pos.parse().unwrap_or(0);
655                                let y: u16 = hex.parse().unwrap_or(0);
656                                if let Some(rgb) = parse_hex_color(hex_val) {
657                                    color_data.insert((x, y, kind.to_string()), rgb);
658                                }
659                            }
660                        }
661                    }
662                }
663            } else if in_styles {
664                for part in line.split_whitespace() {
665                    // Format: x:y:BIUD
666                    let parts: Vec<&str> = part.split(':').collect();
667                    if parts.len() >= 3 {
668                        let x: u16 = parts[0].parse().unwrap_or(0);
669                        let y: u16 = parts[1].parse().unwrap_or(0);
670                        let flags = parts[2];
671                        style_data.insert(
672                            (x, y),
673                            (
674                                flags.contains('B'),
675                                flags.contains('I'),
676                                flags.contains('U'),
677                                flags.contains('D'),
678                            ),
679                        );
680                    }
681                }
682            }
683        }
684
685        // Build cells from text lines
686        if height == 0 {
687            height = text_lines.len() as u16;
688        }
689        if width == 0 && !text_lines.is_empty() {
690            width = text_lines
691                .iter()
692                .map(|l| l.chars().count())
693                .max()
694                .unwrap_or(0) as u16;
695        }
696
697        for y in 0..height {
698            let line = text_lines.get(y as usize).map(|s| s.as_str()).unwrap_or("");
699            let chars: Vec<char> = line.chars().collect();
700
701            for x in 0..width {
702                let symbol = chars.get(x as usize).copied().unwrap_or(' ');
703                let fg = color_data
704                    .get(&(x, y, "fg".to_string()))
705                    .map(|(r, g, b)| Color::rgb(*r, *g, *b));
706                let bg = color_data
707                    .get(&(x, y, "bg".to_string()))
708                    .map(|(r, g, b)| Color::rgb(*r, *g, *b));
709                let (bold, italic, underline, dim) = style_data
710                    .get(&(x, y))
711                    .copied()
712                    .unwrap_or((false, false, false, false));
713
714                cells.push(CapturedCell {
715                    symbol,
716                    fg,
717                    bg,
718                    bold,
719                    italic,
720                    underline,
721                    dim,
722                });
723            }
724        }
725
726        Ok(Self {
727            width,
728            height,
729            cells,
730            include_styles,
731            include_colors,
732        })
733    }
734}
735
736/// Parse hex color string like "#rrggbb"
737fn parse_hex_color(s: &str) -> Option<(u8, u8, u8)> {
738    let s = s.trim_start_matches('#');
739    if s.len() != 6 {
740        return None;
741    }
742    let r = u8::from_str_radix(&s[0..2], 16).ok()?;
743    let g = u8::from_str_radix(&s[2..4], 16).ok()?;
744    let b = u8::from_str_radix(&s[4..6], 16).ok()?;
745    Some((r, g, b))
746}
747
748// =============================================================================
749// Visual Diff
750// =============================================================================
751
752/// Difference between two captures
753#[derive(Debug)]
754pub struct VisualDiff {
755    /// Size mismatch (actual, expected)
756    pub size_mismatch: Option<((u16, u16), (u16, u16))>,
757    /// Cell differences
758    pub differences: Vec<CellDiff>,
759    /// Actual width
760    pub actual_width: u16,
761    /// Actual height
762    pub actual_height: u16,
763    /// Expected width
764    pub expected_width: u16,
765    /// Expected height
766    pub expected_height: u16,
767}
768
769/// Difference in a single cell
770#[derive(Debug)]
771pub struct CellDiff {
772    /// X position
773    pub x: u16,
774    /// Y position
775    pub y: u16,
776    /// Actual cell
777    pub actual: CapturedCell,
778    /// Expected cell
779    pub expected: CapturedCell,
780}
781
782impl VisualDiff {
783    /// Check if there are any differences
784    pub fn has_differences(&self) -> bool {
785        self.size_mismatch.is_some() || !self.differences.is_empty()
786    }
787
788    /// Get summary of differences
789    pub fn summary(&self) -> String {
790        let mut output = String::new();
791
792        if let Some(((aw, ah), (ew, eh))) = self.size_mismatch {
793            output.push_str(&format!(
794                "Size mismatch: actual {}x{}, expected {}x{}\n",
795                aw, ah, ew, eh
796            ));
797            return output;
798        }
799
800        let total = self.differences.len();
801        output.push_str(&format!("Found {} cell difference(s):\n\n", total));
802
803        // Show first 10 differences
804        for (i, diff) in self.differences.iter().take(10).enumerate() {
805            output.push_str(&format!(
806                "  {}. ({}, {}): '{}' -> '{}'\n",
807                i + 1,
808                diff.x,
809                diff.y,
810                diff.expected.symbol,
811                diff.actual.symbol
812            ));
813
814            // Show color diff if applicable
815            if diff.actual.fg != diff.expected.fg {
816                output.push_str(&format!(
817                    "     fg: {:?} -> {:?}\n",
818                    diff.expected.fg, diff.actual.fg
819                ));
820            }
821            if diff.actual.bg != diff.expected.bg {
822                output.push_str(&format!(
823                    "     bg: {:?} -> {:?}\n",
824                    diff.expected.bg, diff.actual.bg
825                ));
826            }
827        }
828
829        if total > 10 {
830            output.push_str(&format!("\n  ... and {} more\n", total - 10));
831        }
832
833        output
834    }
835}
836
837impl std::fmt::Display for VisualDiff {
838    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
839        write!(f, "{}", self.summary())
840    }
841}
842
843// =============================================================================
844// Tests
845// =============================================================================
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850    use crate::render::Cell;
851
852    fn make_buffer(text: &str) -> Buffer {
853        let lines: Vec<&str> = text.lines().collect();
854        let height = lines.len() as u16;
855        let width = lines.iter().map(|l| l.len()).max().unwrap_or(0) as u16;
856
857        let mut buffer = Buffer::new(width.max(1), height.max(1));
858        for (y, line) in lines.iter().enumerate() {
859            for (x, ch) in line.chars().enumerate() {
860                buffer.set(x as u16, y as u16, Cell::new(ch));
861            }
862        }
863        buffer
864    }
865
866    #[test]
867    fn test_visual_capture_from_buffer() {
868        let buffer = make_buffer("Hello\nWorld");
869        let config = VisualTestConfig::default();
870        let capture = VisualCapture::from_buffer(&buffer, &config);
871
872        assert_eq!(capture.width, 5);
873        assert_eq!(capture.height, 2);
874        assert_eq!(capture.get(0, 0).unwrap().symbol, 'H');
875        assert_eq!(capture.get(4, 0).unwrap().symbol, 'o');
876        assert_eq!(capture.get(0, 1).unwrap().symbol, 'W');
877    }
878
879    #[test]
880    fn test_visual_capture_diff_identical() {
881        let buffer = make_buffer("Test");
882        let config = VisualTestConfig::default();
883        let capture1 = VisualCapture::from_buffer(&buffer, &config);
884        let capture2 = VisualCapture::from_buffer(&buffer, &config);
885
886        let diff = capture1.diff(&capture2, 0);
887        assert!(!diff.has_differences());
888    }
889
890    #[test]
891    fn test_visual_capture_diff_different() {
892        let buffer1 = make_buffer("Hello");
893        let buffer2 = make_buffer("World");
894        let config = VisualTestConfig::default();
895        let capture1 = VisualCapture::from_buffer(&buffer1, &config);
896        let capture2 = VisualCapture::from_buffer(&buffer2, &config);
897
898        let diff = capture1.diff(&capture2, 0);
899        assert!(diff.has_differences());
900        assert!(!diff.differences.is_empty());
901    }
902
903    #[test]
904    fn test_visual_capture_diff_size_mismatch() {
905        let buffer1 = make_buffer("Hi");
906        let buffer2 = make_buffer("Hello");
907        let config = VisualTestConfig::default();
908        let capture1 = VisualCapture::from_buffer(&buffer1, &config);
909        let capture2 = VisualCapture::from_buffer(&buffer2, &config);
910
911        let diff = capture1.diff(&capture2, 0);
912        assert!(diff.has_differences());
913        assert!(diff.size_mismatch.is_some());
914    }
915
916    #[test]
917    fn test_captured_cell_matches_exact() {
918        let cell1 = CapturedCell::from_char('A');
919        let cell2 = CapturedCell::from_char('A');
920        assert!(cell1.matches(&cell2, 0, true, true));
921    }
922
923    #[test]
924    fn test_captured_cell_matches_different_char() {
925        let cell1 = CapturedCell::from_char('A');
926        let cell2 = CapturedCell::from_char('B');
927        assert!(!cell1.matches(&cell2, 0, true, true));
928    }
929
930    #[test]
931    fn test_color_tolerance() {
932        let c1 = Some(Color::rgb(100, 100, 100));
933        let c2 = Some(Color::rgb(105, 100, 100));
934
935        // Exact match fails
936        assert!(!colors_match(&c1, &c2, 0));
937
938        // Within tolerance passes
939        assert!(colors_match(&c1, &c2, 10));
940    }
941
942    #[test]
943    fn test_visual_test_config_default() {
944        let config = VisualTestConfig::default();
945        assert_eq!(config.golden_dir, PathBuf::from("tests/golden"));
946        assert_eq!(config.color_tolerance, 0);
947        assert!(config.include_styles);
948        assert!(config.include_colors);
949    }
950
951    #[test]
952    fn test_serialize_deserialize() {
953        let buffer = make_buffer("AB\nCD");
954        let config = VisualTestConfig::default();
955        let capture = VisualCapture::from_buffer(&buffer, &config);
956
957        let serialized = capture.serialize();
958        let deserialized = VisualCapture::deserialize(&serialized).unwrap();
959
960        assert_eq!(capture.width, deserialized.width);
961        assert_eq!(capture.height, deserialized.height);
962        assert_eq!(
963            capture.get(0, 0).unwrap().symbol,
964            deserialized.get(0, 0).unwrap().symbol
965        );
966    }
967
968    #[test]
969    fn test_parse_hex_color() {
970        assert_eq!(parse_hex_color("#ff0000"), Some((255, 0, 0)));
971        assert_eq!(parse_hex_color("#00ff00"), Some((0, 255, 0)));
972        assert_eq!(parse_hex_color("#0000ff"), Some((0, 0, 255)));
973        assert_eq!(parse_hex_color("ffffff"), Some((255, 255, 255)));
974        assert_eq!(parse_hex_color("invalid"), None);
975    }
976
977    // =========================================================================
978    // VisualTestConfig tests
979    // =========================================================================
980
981    #[test]
982    fn test_config_with_dir() {
983        let config = VisualTestConfig::with_dir("custom/path");
984        assert_eq!(config.golden_dir, PathBuf::from("custom/path"));
985    }
986
987    #[test]
988    fn test_config_tolerance() {
989        let config = VisualTestConfig::default().tolerance(10);
990        assert_eq!(config.color_tolerance, 10);
991    }
992
993    #[test]
994    fn test_config_generate_diff() {
995        let config = VisualTestConfig::default().generate_diff(false);
996        assert!(!config.generate_diff);
997    }
998
999    #[test]
1000    fn test_config_include_styles() {
1001        let config = VisualTestConfig::default().include_styles(false);
1002        assert!(!config.include_styles);
1003    }
1004
1005    #[test]
1006    fn test_config_include_colors() {
1007        let config = VisualTestConfig::default().include_colors(false);
1008        assert!(!config.include_colors);
1009    }
1010
1011    #[test]
1012    fn test_config_clone() {
1013        let config = VisualTestConfig::default().tolerance(5);
1014        let cloned = config.clone();
1015        assert_eq!(cloned.color_tolerance, 5);
1016    }
1017
1018    // =========================================================================
1019    // VisualTest tests
1020    // =========================================================================
1021
1022    #[test]
1023    fn test_visual_test_new() {
1024        let test = VisualTest::new("my_test");
1025        assert_eq!(test.name, "my_test");
1026        assert!(test.group.is_none());
1027    }
1028
1029    #[test]
1030    fn test_visual_test_with_config() {
1031        let config = VisualTestConfig::default().tolerance(10);
1032        let test = VisualTest::with_config("test", config);
1033        assert_eq!(test.config.color_tolerance, 10);
1034    }
1035
1036    #[test]
1037    fn test_visual_test_group() {
1038        let test = VisualTest::new("test").group("buttons");
1039        assert_eq!(test.group, Some("buttons".to_string()));
1040    }
1041
1042    #[test]
1043    fn test_visual_test_golden_path() {
1044        let test = VisualTest::new("button_test");
1045        let path = test.golden_path();
1046        assert!(path.to_string_lossy().contains("button_test.golden"));
1047    }
1048
1049    #[test]
1050    fn test_visual_test_golden_path_with_group() {
1051        let test = VisualTest::new("button_test").group("widgets");
1052        let path = test.golden_path();
1053        assert!(path.to_string_lossy().contains("widgets"));
1054        assert!(path.to_string_lossy().contains("button_test.golden"));
1055    }
1056
1057    #[test]
1058    fn test_visual_test_compare() {
1059        let test = VisualTest::new("test");
1060        let buffer1 = make_buffer("Hello");
1061        let buffer2 = make_buffer("Hello");
1062
1063        let diff = test.compare(&buffer1, &buffer2);
1064        assert!(!diff.has_differences());
1065    }
1066
1067    // =========================================================================
1068    // VisualTestResult tests
1069    // =========================================================================
1070
1071    #[test]
1072    fn test_visual_test_result_equality() {
1073        assert_eq!(VisualTestResult::Passed, VisualTestResult::Passed);
1074        assert_ne!(VisualTestResult::Passed, VisualTestResult::Failed);
1075    }
1076
1077    #[test]
1078    fn test_visual_test_result_copy() {
1079        let result = VisualTestResult::Created;
1080        let copied = result;
1081        assert_eq!(copied, VisualTestResult::Created);
1082    }
1083
1084    // =========================================================================
1085    // CapturedCell tests
1086    // =========================================================================
1087
1088    #[test]
1089    fn test_captured_cell_default() {
1090        let cell = CapturedCell::default();
1091        assert_eq!(cell.symbol, ' ');
1092        assert!(cell.fg.is_none());
1093        assert!(cell.bg.is_none());
1094        assert!(!cell.bold);
1095        assert!(!cell.italic);
1096        assert!(!cell.underline);
1097        assert!(!cell.dim);
1098    }
1099
1100    #[test]
1101    fn test_captured_cell_from_char() {
1102        let cell = CapturedCell::from_char('X');
1103        assert_eq!(cell.symbol, 'X');
1104        assert!(cell.fg.is_none());
1105    }
1106
1107    #[test]
1108    fn test_captured_cell_matches_ignore_colors() {
1109        let cell1 = CapturedCell {
1110            symbol: 'A',
1111            fg: Some(Color::rgb(255, 0, 0)),
1112            ..Default::default()
1113        };
1114        let cell2 = CapturedCell {
1115            symbol: 'A',
1116            fg: Some(Color::rgb(0, 255, 0)),
1117            ..Default::default()
1118        };
1119
1120        // Without colors, should match
1121        assert!(cell1.matches(&cell2, 0, false, false));
1122        // With colors, should not match
1123        assert!(!cell1.matches(&cell2, 0, false, true));
1124    }
1125
1126    #[test]
1127    fn test_captured_cell_matches_ignore_styles() {
1128        let cell1 = CapturedCell {
1129            symbol: 'A',
1130            bold: true,
1131            ..Default::default()
1132        };
1133        let cell2 = CapturedCell {
1134            symbol: 'A',
1135            bold: false,
1136            ..Default::default()
1137        };
1138
1139        // Without styles, should match
1140        assert!(cell1.matches(&cell2, 0, false, false));
1141        // With styles, should not match
1142        assert!(!cell1.matches(&cell2, 0, true, false));
1143    }
1144
1145    #[test]
1146    fn test_captured_cell_clone() {
1147        let cell = CapturedCell {
1148            symbol: 'X',
1149            bold: true,
1150            fg: Some(Color::rgb(100, 100, 100)),
1151            ..Default::default()
1152        };
1153        let cloned = cell.clone();
1154        assert_eq!(cloned.symbol, 'X');
1155        assert!(cloned.bold);
1156    }
1157
1158    // =========================================================================
1159    // Color matching tests
1160    // =========================================================================
1161
1162    #[test]
1163    fn test_colors_match_both_none() {
1164        assert!(colors_match(&None, &None, 0));
1165    }
1166
1167    #[test]
1168    fn test_colors_match_one_none() {
1169        let color = Some(Color::rgb(100, 100, 100));
1170        // Without tolerance, mismatched
1171        assert!(!colors_match(&color, &None, 0));
1172        assert!(!colors_match(&None, &color, 0));
1173        // With max tolerance, matches
1174        assert!(colors_match(&color, &None, 255));
1175    }
1176
1177    #[test]
1178    fn test_colors_match_exact() {
1179        let c1 = Some(Color::rgb(100, 150, 200));
1180        let c2 = Some(Color::rgb(100, 150, 200));
1181        assert!(colors_match(&c1, &c2, 0));
1182    }
1183
1184    #[test]
1185    fn test_colors_match_within_tolerance() {
1186        let c1 = Some(Color::rgb(100, 100, 100));
1187        let c2 = Some(Color::rgb(105, 95, 102));
1188
1189        assert!(!colors_match(&c1, &c2, 0));
1190        assert!(!colors_match(&c1, &c2, 4));
1191        assert!(colors_match(&c1, &c2, 5));
1192        assert!(colors_match(&c1, &c2, 10));
1193    }
1194
1195    // =========================================================================
1196    // VisualCapture tests
1197    // =========================================================================
1198
1199    #[test]
1200    fn test_capture_get_out_of_bounds() {
1201        let buffer = make_buffer("AB");
1202        let config = VisualTestConfig::default();
1203        let capture = VisualCapture::from_buffer(&buffer, &config);
1204
1205        assert!(capture.get(0, 0).is_some());
1206        assert!(capture.get(100, 100).is_none());
1207    }
1208
1209    #[test]
1210    fn test_capture_serialize_contains_header() {
1211        let buffer = make_buffer("Test");
1212        let config = VisualTestConfig::default();
1213        let capture = VisualCapture::from_buffer(&buffer, &config);
1214
1215        let serialized = capture.serialize();
1216        assert!(serialized.contains("# Visual Golden File"));
1217        assert!(serialized.contains("# Size:"));
1218        assert!(serialized.contains("## Text"));
1219    }
1220
1221    #[test]
1222    fn test_capture_serialize_contains_text() {
1223        let buffer = make_buffer("Hello\nWorld");
1224        let config = VisualTestConfig::default();
1225        let capture = VisualCapture::from_buffer(&buffer, &config);
1226
1227        let serialized = capture.serialize();
1228        assert!(serialized.contains("Hello"));
1229        assert!(serialized.contains("World"));
1230    }
1231
1232    // =========================================================================
1233    // VisualDiff tests
1234    // =========================================================================
1235
1236    #[test]
1237    fn test_diff_has_differences_size_mismatch() {
1238        let diff = VisualDiff {
1239            size_mismatch: Some(((10, 5), (20, 10))),
1240            differences: vec![],
1241            actual_width: 10,
1242            actual_height: 5,
1243            expected_width: 20,
1244            expected_height: 10,
1245        };
1246        assert!(diff.has_differences());
1247    }
1248
1249    #[test]
1250    fn test_diff_has_differences_cell_diff() {
1251        let diff = VisualDiff {
1252            size_mismatch: None,
1253            differences: vec![CellDiff {
1254                x: 0,
1255                y: 0,
1256                actual: CapturedCell::from_char('A'),
1257                expected: CapturedCell::from_char('B'),
1258            }],
1259            actual_width: 10,
1260            actual_height: 5,
1261            expected_width: 10,
1262            expected_height: 5,
1263        };
1264        assert!(diff.has_differences());
1265    }
1266
1267    #[test]
1268    fn test_diff_no_differences() {
1269        let diff = VisualDiff {
1270            size_mismatch: None,
1271            differences: vec![],
1272            actual_width: 10,
1273            actual_height: 5,
1274            expected_width: 10,
1275            expected_height: 5,
1276        };
1277        assert!(!diff.has_differences());
1278    }
1279
1280    #[test]
1281    fn test_diff_summary_size_mismatch() {
1282        let diff = VisualDiff {
1283            size_mismatch: Some(((10, 5), (20, 10))),
1284            differences: vec![],
1285            actual_width: 10,
1286            actual_height: 5,
1287            expected_width: 20,
1288            expected_height: 10,
1289        };
1290
1291        let summary = diff.summary();
1292        assert!(summary.contains("Size mismatch"));
1293        assert!(summary.contains("10x5"));
1294        assert!(summary.contains("20x10"));
1295    }
1296
1297    #[test]
1298    fn test_diff_summary_cell_differences() {
1299        let diff = VisualDiff {
1300            size_mismatch: None,
1301            differences: vec![
1302                CellDiff {
1303                    x: 0,
1304                    y: 0,
1305                    actual: CapturedCell::from_char('A'),
1306                    expected: CapturedCell::from_char('B'),
1307                },
1308                CellDiff {
1309                    x: 1,
1310                    y: 1,
1311                    actual: CapturedCell::from_char('X'),
1312                    expected: CapturedCell::from_char('Y'),
1313                },
1314            ],
1315            actual_width: 10,
1316            actual_height: 5,
1317            expected_width: 10,
1318            expected_height: 5,
1319        };
1320
1321        let summary = diff.summary();
1322        assert!(summary.contains("2 cell difference"));
1323    }
1324
1325    #[test]
1326    fn test_diff_summary_many_differences() {
1327        let mut differences = Vec::new();
1328        for i in 0..15 {
1329            differences.push(CellDiff {
1330                x: i,
1331                y: 0,
1332                actual: CapturedCell::from_char('A'),
1333                expected: CapturedCell::from_char('B'),
1334            });
1335        }
1336
1337        let diff = VisualDiff {
1338            size_mismatch: None,
1339            differences,
1340            actual_width: 20,
1341            actual_height: 5,
1342            expected_width: 20,
1343            expected_height: 5,
1344        };
1345
1346        let summary = diff.summary();
1347        assert!(summary.contains("15 cell difference"));
1348        assert!(summary.contains("... and 5 more"));
1349    }
1350
1351    #[test]
1352    fn test_diff_display() {
1353        let diff = VisualDiff {
1354            size_mismatch: None,
1355            differences: vec![],
1356            actual_width: 10,
1357            actual_height: 5,
1358            expected_width: 10,
1359            expected_height: 5,
1360        };
1361
1362        let display = format!("{}", diff);
1363        assert!(display.contains("0 cell difference"));
1364    }
1365
1366    // =========================================================================
1367    // Parse hex color tests
1368    // =========================================================================
1369
1370    #[test]
1371    fn test_parse_hex_color_short() {
1372        assert_eq!(parse_hex_color("abc"), None);
1373    }
1374
1375    #[test]
1376    fn test_parse_hex_color_long() {
1377        assert_eq!(parse_hex_color("#aabbccdd"), None);
1378    }
1379
1380    #[test]
1381    fn test_parse_hex_color_gray() {
1382        assert_eq!(parse_hex_color("#808080"), Some((128, 128, 128)));
1383    }
1384}