Skip to main content

revue/core/app/
snapshot.rs

1//! Snapshot testing utilities for UI components
2
3use crate::layout::Rect;
4use crate::render::Buffer;
5use crate::widget::{RenderContext, View};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::constants::MAX_SNAPSHOT_FILE_SIZE;
10
11/// Snapshot test result
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum SnapshotResult {
14    /// Snapshot matches
15    Match,
16    /// Snapshot differs
17    Mismatch {
18        /// Expected snapshot content
19        expected: String,
20        /// Actual rendered content
21        actual: String,
22        /// Line-by-line differences (line number, expected, actual)
23        diff: Vec<(usize, String, String)>,
24    },
25    /// New snapshot created
26    Created,
27    /// Snapshot file not found
28    NotFound,
29}
30
31impl SnapshotResult {
32    /// Check if snapshot matches
33    pub fn is_match(&self) -> bool {
34        matches!(self, SnapshotResult::Match | SnapshotResult::Created)
35    }
36
37    /// Check if snapshot mismatches
38    pub fn is_mismatch(&self) -> bool {
39        matches!(self, SnapshotResult::Mismatch { .. })
40    }
41}
42
43/// Snapshot configuration
44#[derive(Clone, Debug)]
45pub struct SnapshotConfig {
46    /// Directory to store snapshots
47    pub snapshot_dir: PathBuf,
48    /// Whether to update snapshots
49    pub update_snapshots: bool,
50    /// Include ANSI colors in snapshot
51    pub include_colors: bool,
52    /// Include modifiers (bold, italic, etc.)
53    pub include_modifiers: bool,
54}
55
56impl Default for SnapshotConfig {
57    fn default() -> Self {
58        Self {
59            snapshot_dir: PathBuf::from("snapshots"),
60            update_snapshots: std::env::var("UPDATE_SNAPSHOTS").is_ok(),
61            include_colors: false,
62            include_modifiers: false,
63        }
64    }
65}
66
67impl SnapshotConfig {
68    /// Set snapshot directory
69    pub fn snapshot_dir(mut self, dir: impl AsRef<Path>) -> Self {
70        self.snapshot_dir = dir.as_ref().to_path_buf();
71        self
72    }
73
74    /// Set whether to update snapshots
75    pub fn update_snapshots(mut self, update: bool) -> Self {
76        self.update_snapshots = update;
77        self
78    }
79
80    /// Include colors in snapshot
81    pub fn include_colors(mut self, include: bool) -> Self {
82        self.include_colors = include;
83        self
84    }
85
86    /// Include modifiers in snapshot
87    pub fn include_modifiers(mut self, include: bool) -> Self {
88        self.include_modifiers = include;
89        self
90    }
91}
92
93/// Snapshot tester
94pub struct Snapshot {
95    config: SnapshotConfig,
96}
97
98impl Snapshot {
99    /// Create a new snapshot tester
100    pub fn new() -> Self {
101        Self {
102            config: SnapshotConfig::default(),
103        }
104    }
105
106    /// Create with custom config
107    pub fn with_config(config: SnapshotConfig) -> Self {
108        Self { config }
109    }
110
111    /// Set config
112    pub fn config(mut self, config: SnapshotConfig) -> Self {
113        self.config = config;
114        self
115    }
116
117    /// Render a view to a buffer
118    pub fn render_view<V: View>(&self, view: &V, width: u16, height: u16) -> Buffer {
119        let mut buffer = Buffer::new(width, height);
120        let area = Rect::new(0, 0, width, height);
121        let mut ctx = RenderContext::new(&mut buffer, area);
122        view.render(&mut ctx);
123        buffer
124    }
125
126    /// Convert buffer to string representation
127    pub fn buffer_to_string(&self, buffer: &Buffer) -> String {
128        let mut lines = Vec::new();
129
130        for y in 0..buffer.height() {
131            let mut line = String::new();
132            for x in 0..buffer.width() {
133                if let Some(cell) = buffer.get(x, y) {
134                    if self.config.include_colors {
135                        if let Some(fg) = cell.fg {
136                            line.push_str(&format!("[38;2;{};{};{}m", fg.r, fg.g, fg.b));
137                        }
138                        if let Some(bg) = cell.bg {
139                            line.push_str(&format!("[48;2;{};{};{}m", bg.r, bg.g, bg.b));
140                        }
141                    }
142                    if self.config.include_modifiers {
143                        if cell.modifier.contains(crate::render::Modifier::BOLD) {
144                            line.push_str("");
145                        }
146                        if cell.modifier.contains(crate::render::Modifier::ITALIC) {
147                            line.push_str("");
148                        }
149                        if cell.modifier.contains(crate::render::Modifier::UNDERLINE) {
150                            line.push_str("");
151                        }
152                    }
153                    line.push(cell.symbol);
154                    if self.config.include_colors || self.config.include_modifiers {
155                        line.push_str("");
156                    }
157                } else {
158                    line.push(' ');
159                }
160            }
161            // Trim trailing spaces but keep line structure
162            let trimmed = line.trim_end();
163            lines.push(trimmed.to_string());
164        }
165
166        // Remove trailing empty lines
167        while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
168            lines.pop();
169        }
170
171        lines.join(
172            "
173",
174        )
175    }
176
177    /// Get snapshot file path
178    fn snapshot_path(&self, name: &str) -> PathBuf {
179        self.config.snapshot_dir.join(format!("{}.snap", name))
180    }
181
182    /// Assert snapshot matches
183    pub fn assert_snapshot<V: View>(
184        &self,
185        name: &str,
186        view: &V,
187        width: u16,
188        height: u16,
189    ) -> SnapshotResult {
190        let buffer = self.render_view(view, width, height);
191        let actual = self.buffer_to_string(&buffer);
192        self.assert_snapshot_string(name, &actual)
193    }
194
195    /// Assert snapshot string matches
196    pub fn assert_snapshot_string(&self, name: &str, actual: &str) -> SnapshotResult {
197        let path = self.snapshot_path(name);
198
199        // Ensure directory exists
200        if let Some(parent) = path.parent() {
201            let _ = fs::create_dir_all(parent);
202        }
203
204        // Read existing snapshot with size validation
205        let expected = if path.exists() {
206            // Check file size to prevent DoS
207            let metadata_ok = fs::metadata(&path)
208                .map(|m| m.len() <= MAX_SNAPSHOT_FILE_SIZE)
209                .unwrap_or(false);
210
211            if metadata_ok {
212                fs::read_to_string(&path).ok()
213            } else {
214                None
215            }
216        } else {
217            None
218        };
219
220        match expected {
221            Some(expected) if expected == *actual => SnapshotResult::Match,
222            Some(_) if self.config.update_snapshots => {
223                fs::write(&path, actual).ok();
224                SnapshotResult::Created
225            }
226            Some(expected) => {
227                let diff = self.compute_diff(&expected, actual);
228                SnapshotResult::Mismatch {
229                    expected,
230                    actual: actual.to_string(),
231                    diff,
232                }
233            }
234            None if self.config.update_snapshots => {
235                fs::write(&path, actual).ok();
236                SnapshotResult::Created
237            }
238            None => {
239                fs::write(&path, actual).ok();
240                SnapshotResult::Created
241            }
242        }
243    }
244
245    /// Compute line-by-line diff
246    fn compute_diff(&self, expected: &str, actual: &str) -> Vec<(usize, String, String)> {
247        let expected_lines: Vec<&str> = expected.lines().collect();
248        let actual_lines: Vec<&str> = actual.lines().collect();
249
250        let mut diff = Vec::new();
251        let max_lines = expected_lines.len().max(actual_lines.len());
252
253        for i in 0..max_lines {
254            let exp = expected_lines.get(i).copied().unwrap_or("");
255            let act = actual_lines.get(i).copied().unwrap_or("");
256
257            if exp != act {
258                diff.push((i + 1, exp.to_string(), act.to_string()));
259            }
260        }
261
262        diff
263    }
264
265    /// Format diff for display
266    pub fn format_diff(result: &SnapshotResult) -> String {
267        match result {
268            SnapshotResult::Match => "Snapshot matches!".to_string(),
269            SnapshotResult::Created => "Snapshot created!".to_string(),
270            SnapshotResult::NotFound => "Snapshot not found!".to_string(),
271            SnapshotResult::Mismatch { diff, .. } => {
272                let mut output = String::from(
273                    "Snapshot mismatch:
274",
275                );
276                for (line, expected, actual) in diff {
277                    output.push_str(&format!(
278                        "Line {}:
279  - {}
280  + {}
281",
282                        line, expected, actual
283                    ));
284                }
285                output
286            }
287        }
288    }
289}
290
291impl Default for Snapshot {
292    fn default() -> Self {
293        Self::new()
294    }
295}
296
297/// Convenience function to create a snapshot tester
298pub fn snapshot() -> Snapshot {
299    Snapshot::new()
300}
301
302/// Assert that a view matches its snapshot
303///
304/// # Panics
305/// Panics if the snapshot doesn't match and UPDATE_SNAPSHOTS is not set
306#[macro_export]
307macro_rules! assert_snapshot {
308    ($name:expr, $view:expr) => {
309        assert_snapshot!($name, $view, 80, 24)
310    };
311    ($name:expr, $view:expr, $width:expr, $height:expr) => {{
312        let snap = $crate::app::snapshot::snapshot();
313        let result = snap.assert_snapshot($name, $view, $width, $height);
314        if result.is_mismatch() {
315            panic!(
316                "Snapshot '{}' mismatch!
317{}",
318                $name,
319                $crate::app::snapshot::Snapshot::format_diff(&result)
320            );
321        }
322    }};
323}
324// KEEP HERE - Private implementation tests (accesses private fields)
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::widget::Text;
330
331    #[test]
332    fn test_snapshot_config_default() {
333        let config = SnapshotConfig::default();
334        assert!(!config.include_colors);
335        assert!(!config.include_modifiers);
336    }
337
338    #[test]
339    fn test_snapshot_config_builder() {
340        let config = SnapshotConfig::default()
341            .snapshot_dir("test_snapshots")
342            .include_colors(true)
343            .include_modifiers(true);
344
345        assert!(config.include_colors);
346        assert!(config.include_modifiers);
347    }
348
349    #[test]
350    fn test_snapshot_new() {
351        let snap = Snapshot::new();
352        assert!(!snap.config.include_colors);
353    }
354
355    #[test]
356    fn test_render_view() {
357        let snap = Snapshot::new();
358        let text = Text::new("Hello");
359        let buffer = snap.render_view(&text, 10, 3);
360
361        assert_eq!(buffer.width(), 10);
362        assert_eq!(buffer.height(), 3);
363    }
364
365    #[test]
366    fn test_buffer_to_string() {
367        let snap = Snapshot::new();
368        let text = Text::new("Test");
369        let buffer = snap.render_view(&text, 10, 1);
370        let output = snap.buffer_to_string(&buffer);
371
372        assert!(output.contains("Test"));
373    }
374
375    #[test]
376    fn test_snapshot_result_is_match() {
377        assert!(SnapshotResult::Match.is_match());
378        assert!(SnapshotResult::Created.is_match());
379        assert!(!SnapshotResult::NotFound.is_match());
380    }
381
382    #[test]
383    fn test_snapshot_result_is_mismatch() {
384        let mismatch = SnapshotResult::Mismatch {
385            expected: "a".to_string(),
386            actual: "b".to_string(),
387            diff: vec![(1, "a".to_string(), "b".to_string())],
388        };
389        assert!(mismatch.is_mismatch());
390        assert!(!SnapshotResult::Match.is_mismatch());
391    }
392
393    #[test]
394    fn test_compute_diff() {
395        let snap = Snapshot::new();
396        let diff = snap.compute_diff("line1\nline2", "line1\nchanged");
397
398        assert_eq!(diff.len(), 1);
399        assert_eq!(diff[0].0, 2);
400        assert_eq!(diff[0].1, "line2");
401        assert_eq!(diff[0].2, "changed");
402    }
403
404    #[test]
405    fn test_format_diff_match() {
406        let output = Snapshot::format_diff(&SnapshotResult::Match);
407        assert!(output.contains("matches"));
408    }
409
410    #[test]
411    fn test_format_diff_mismatch() {
412        let result = SnapshotResult::Mismatch {
413            expected: "a".to_string(),
414            actual: "b".to_string(),
415            diff: vec![(1, "a".to_string(), "b".to_string())],
416        };
417        let output = Snapshot::format_diff(&result);
418        assert!(output.contains("mismatch"));
419        assert!(output.contains("Line 1"));
420    }
421
422    #[test]
423    fn test_snapshot_helper() {
424        let snap = snapshot();
425        // Verify snapshot helper returns valid instance
426        assert!(snap.config.snapshot_dir.as_os_str().len() > 0);
427    }
428
429    // =========================================================================
430    // Additional snapshot tests
431    // =========================================================================
432
433    #[test]
434    fn test_snapshot_config_snapshot_dir_builder() {
435        let config = SnapshotConfig::default().snapshot_dir("/tmp/snapshots");
436        assert_eq!(config.snapshot_dir, PathBuf::from("/tmp/snapshots"));
437    }
438
439    #[test]
440    fn test_snapshot_config_update_snapshots_builder() {
441        let config = SnapshotConfig::default().update_snapshots(true);
442        assert!(config.update_snapshots);
443    }
444
445    #[test]
446    fn test_snapshot_config_include_colors_builder() {
447        let config = SnapshotConfig::default().include_colors(true);
448        assert!(config.include_colors);
449    }
450
451    #[test]
452    fn test_snapshot_config_include_modifiers_builder() {
453        let config = SnapshotConfig::default().include_modifiers(true);
454        assert!(config.include_modifiers);
455    }
456
457    #[test]
458    fn test_snapshot_config_clone() {
459        let config = SnapshotConfig::default();
460        let cloned = config.clone();
461        assert_eq!(config.snapshot_dir, cloned.snapshot_dir);
462        assert_eq!(config.include_colors, cloned.include_colors);
463    }
464
465    #[test]
466    fn test_snapshot_default() {
467        let snap = Snapshot::default();
468        assert!(!snap.config.include_colors);
469        assert!(!snap.config.include_modifiers);
470    }
471
472    #[test]
473    fn test_snapshot_with_config() {
474        let config = SnapshotConfig {
475            snapshot_dir: PathBuf::from("test"),
476            update_snapshots: true,
477            include_colors: true,
478            include_modifiers: true,
479        };
480        let snap = Snapshot::with_config(config.clone());
481        assert_eq!(snap.config.snapshot_dir, PathBuf::from("test"));
482        assert!(snap.config.include_colors);
483        assert!(snap.config.include_modifiers);
484    }
485
486    #[test]
487    fn test_snapshot_config_chaining() {
488        let config = SnapshotConfig::default()
489            .snapshot_dir("test")
490            .include_colors(true);
491        assert_eq!(config.snapshot_dir, PathBuf::from("test"));
492        assert!(config.include_colors);
493    }
494
495    #[test]
496    fn test_buffer_to_string_with_colors() {
497        let config = SnapshotConfig::default().include_colors(true);
498        let snap = Snapshot::with_config(config);
499        let text = Text::new("Test");
500        let buffer = snap.render_view(&text, 10, 1);
501        let output = snap.buffer_to_string(&buffer);
502        // Should include ANSI color codes
503        assert!(output.contains("Test") || !output.is_empty());
504    }
505
506    #[test]
507    fn test_buffer_to_string_with_modifiers() {
508        let config = SnapshotConfig::default().include_modifiers(true);
509        let snap = Snapshot::with_config(config);
510        let text = Text::new("Test");
511        let buffer = snap.render_view(&text, 10, 1);
512        let output = snap.buffer_to_string(&buffer);
513        // Should contain text
514        assert!(output.contains("Test") || !output.is_empty());
515    }
516
517    #[test]
518    fn test_compute_diff_empty() {
519        let snap = Snapshot::new();
520        let diff = snap.compute_diff("", "");
521        assert!(diff.is_empty());
522    }
523
524    #[test]
525    fn test_compute_diff_no_differences() {
526        let snap = Snapshot::new();
527        let diff = snap.compute_diff("same\ncontent", "same\ncontent");
528        assert!(diff.is_empty());
529    }
530
531    #[test]
532    fn test_compute_diff_multiple_lines() {
533        let snap = Snapshot::new();
534        let diff = snap.compute_diff("line1\nline2\nline3", "line1\nchanged\nline3");
535        assert_eq!(diff.len(), 1);
536        assert_eq!(diff[0].0, 2);
537    }
538
539    #[test]
540    fn test_compute_diff_different_lengths() {
541        let snap = Snapshot::new();
542        let diff = snap.compute_diff("line1", "line1\nline2\nline3");
543        assert_eq!(diff.len(), 2);
544    }
545
546    #[test]
547    fn test_format_diff_created() {
548        let output = Snapshot::format_diff(&SnapshotResult::Created);
549        assert!(output.contains("created"));
550    }
551
552    #[test]
553    fn test_format_diff_not_found() {
554        let output = Snapshot::format_diff(&SnapshotResult::NotFound);
555        assert!(output.contains("not found"));
556    }
557
558    #[test]
559    fn test_snapshot_result_clone() {
560        let result = SnapshotResult::Match;
561        let cloned = result.clone();
562        assert_eq!(result, cloned);
563    }
564
565    #[test]
566    fn test_snapshot_result_mismatch_clone() {
567        let result = SnapshotResult::Mismatch {
568            expected: "a".to_string(),
569            actual: "b".to_string(),
570            diff: vec![],
571        };
572        let cloned = result.clone();
573        assert_eq!(result, cloned);
574    }
575
576    #[test]
577    fn test_snapshot_result_partial_eq() {
578        assert_eq!(SnapshotResult::Match, SnapshotResult::Match);
579        assert_eq!(SnapshotResult::Created, SnapshotResult::Created);
580        assert_ne!(SnapshotResult::Match, SnapshotResult::NotFound);
581    }
582
583    #[test]
584    fn test_render_view_with_dimensions() {
585        let snap = Snapshot::new();
586        let text = Text::new("Hello World");
587        let buffer = snap.render_view(&text, 20, 5);
588        assert_eq!(buffer.width(), 20);
589        assert_eq!(buffer.height(), 5);
590    }
591
592    #[test]
593    fn test_render_view_minimal() {
594        let snap = Snapshot::new();
595        let text = Text::new("X");
596        let buffer = snap.render_view(&text, 1, 1);
597        assert_eq!(buffer.width(), 1);
598        assert_eq!(buffer.height(), 1);
599    }
600
601    #[test]
602    fn test_buffer_to_string_trims_trailing_empty_lines() {
603        let snap = Snapshot::new();
604        let text = Text::new("Test");
605        let buffer = snap.render_view(&text, 10, 5);
606        let output = snap.buffer_to_string(&buffer);
607        // Should not have trailing empty lines
608        let lines: Vec<&str> = output.lines().collect();
609        if let Some(last) = lines.last() {
610            assert!(!last.is_empty());
611        }
612    }
613
614    #[test]
615    fn test_snapshot_config_default_update_snapshots() {
616        let config = SnapshotConfig::default();
617        // update_snapshots depends on environment variable
618        // Just verify it's a bool
619        let _ = config.update_snapshots;
620    }
621
622    #[test]
623    fn test_snapshot_result_debug() {
624        let result = SnapshotResult::Match;
625        let debug_str = format!("{:?}", result);
626        assert!(debug_str.contains("Match"));
627    }
628}