1use crate::render::{Buffer, Modifier};
43use crate::style::Color;
44use std::collections::HashMap;
45use std::fs;
46use std::path::{Path, PathBuf};
47
48#[derive(Debug, Clone)]
54pub struct VisualTestConfig {
55 pub golden_dir: PathBuf,
57 pub update_mode: bool,
59 pub color_tolerance: u8,
61 pub generate_diff: bool,
63 pub fail_on_missing: bool,
65 pub include_styles: bool,
67 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 pub fn with_dir(dir: impl Into<PathBuf>) -> Self {
92 Self {
93 golden_dir: dir.into(),
94 ..Default::default()
95 }
96 }
97
98 pub fn tolerance(mut self, tolerance: u8) -> Self {
100 self.color_tolerance = tolerance;
101 self
102 }
103
104 pub fn generate_diff(mut self, enable: bool) -> Self {
106 self.generate_diff = enable;
107 self
108 }
109
110 pub fn include_styles(mut self, enable: bool) -> Self {
112 self.include_styles = enable;
113 self
114 }
115
116 pub fn include_colors(mut self, enable: bool) -> Self {
118 self.include_colors = enable;
119 self
120 }
121}
122
123pub struct VisualTest {
129 name: String,
131 config: VisualTestConfig,
133 group: Option<String>,
135}
136
137impl VisualTest {
138 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 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 pub fn group(mut self, group: impl Into<String>) -> Self {
158 self.group = Some(group.into());
159 self
160 }
161
162 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum VisualTestResult {
254 Passed,
256 Created,
258 Updated,
260 Failed,
262}
263
264#[derive(Debug, Clone)]
270pub struct VisualCapture {
271 pub width: u16,
273 pub height: u16,
275 cells: Vec<CapturedCell>,
277 include_styles: bool,
279 include_colors: bool,
281}
282
283#[derive(Debug, Clone, PartialEq)]
285pub struct CapturedCell {
286 pub symbol: char,
288 pub fg: Option<Color>,
290 pub bg: Option<Color>,
292 pub bold: bool,
294 pub italic: bool,
296 pub underline: bool,
298 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 pub fn from_char(ch: char) -> Self {
319 Self {
320 symbol: ch,
321 ..Default::default()
322 }
323 }
324
325 pub fn matches(
327 &self,
328 other: &Self,
329 tolerance: u8,
330 include_styles: bool,
331 include_colors: bool,
332 ) -> bool {
333 if self.symbol != other.symbol {
335 return false;
336 }
337
338 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 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
362fn 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 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
383fn color_to_rgb(color: &Color) -> (u8, u8, u8) {
385 (color.r, color.g, color.b)
386}
387
388impl VisualCapture {
389 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 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 pub fn diff(&self, other: &Self, tolerance: u8) -> VisualDiff {
445 let mut differences = Vec::new();
446
447 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 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 pub fn save(&self, path: &Path) -> std::io::Result<()> {
493 let content = self.serialize();
494 fs::write(path, content)
495 }
496
497 pub fn load(path: &Path) -> std::io::Result<Self> {
499 let content = fs::read_to_string(path)?;
500 Self::deserialize(&content)
501 }
502
503 fn serialize(&self) -> String {
505 let mut output = String::new();
506
507 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 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 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 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 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 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 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 if in_text {
647 text_lines.push(line.to_string());
648 } else if in_colors {
649 for part in line.split_whitespace() {
650 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 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 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
736fn 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#[derive(Debug)]
754pub struct VisualDiff {
755 pub size_mismatch: Option<((u16, u16), (u16, u16))>,
757 pub differences: Vec<CellDiff>,
759 pub actual_width: u16,
761 pub actual_height: u16,
763 pub expected_width: u16,
765 pub expected_height: u16,
767}
768
769#[derive(Debug)]
771pub struct CellDiff {
772 pub x: u16,
774 pub y: u16,
776 pub actual: CapturedCell,
778 pub expected: CapturedCell,
780}
781
782impl VisualDiff {
783 pub fn has_differences(&self) -> bool {
785 self.size_mismatch.is_some() || !self.differences.is_empty()
786 }
787
788 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 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 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#[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 assert!(!colors_match(&c1, &c2, 0));
937
938 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 #[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 #[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 #[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 #[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 assert!(cell1.matches(&cell2, 0, false, false));
1122 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 assert!(cell1.matches(&cell2, 0, false, false));
1141 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 #[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 assert!(!colors_match(&color, &None, 0));
1172 assert!(!colors_match(&None, &color, 0));
1173 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 #[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 #[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 #[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}