revue/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
9/// Snapshot test result
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum SnapshotResult {
12    /// Snapshot matches
13    Match,
14    /// Snapshot differs
15    Mismatch {
16        /// Expected snapshot content
17        expected: String,
18        /// Actual rendered content
19        actual: String,
20        /// Line-by-line differences (line number, expected, actual)
21        diff: Vec<(usize, String, String)>,
22    },
23    /// New snapshot created
24    Created,
25    /// Snapshot file not found
26    NotFound,
27}
28
29impl SnapshotResult {
30    /// Check if snapshot matches
31    pub fn is_match(&self) -> bool {
32        matches!(self, SnapshotResult::Match | SnapshotResult::Created)
33    }
34
35    /// Check if snapshot mismatches
36    pub fn is_mismatch(&self) -> bool {
37        matches!(self, SnapshotResult::Mismatch { .. })
38    }
39}
40
41/// Snapshot configuration
42#[derive(Clone, Debug)]
43pub struct SnapshotConfig {
44    /// Directory to store snapshots
45    pub snapshot_dir: PathBuf,
46    /// Whether to update snapshots
47    pub update_snapshots: bool,
48    /// Include ANSI colors in snapshot
49    pub include_colors: bool,
50    /// Include modifiers (bold, italic, etc.)
51    pub include_modifiers: bool,
52}
53
54impl Default for SnapshotConfig {
55    fn default() -> Self {
56        Self {
57            snapshot_dir: PathBuf::from("snapshots"),
58            update_snapshots: std::env::var("UPDATE_SNAPSHOTS").is_ok(),
59            include_colors: false,
60            include_modifiers: false,
61        }
62    }
63}
64
65impl SnapshotConfig {
66    /// Set snapshot directory
67    pub fn snapshot_dir(mut self, dir: impl AsRef<Path>) -> Self {
68        self.snapshot_dir = dir.as_ref().to_path_buf();
69        self
70    }
71
72    /// Set whether to update snapshots
73    pub fn update_snapshots(mut self, update: bool) -> Self {
74        self.update_snapshots = update;
75        self
76    }
77
78    /// Include colors in snapshot
79    pub fn include_colors(mut self, include: bool) -> Self {
80        self.include_colors = include;
81        self
82    }
83
84    /// Include modifiers in snapshot
85    pub fn include_modifiers(mut self, include: bool) -> Self {
86        self.include_modifiers = include;
87        self
88    }
89}
90
91/// Snapshot tester
92pub struct Snapshot {
93    config: SnapshotConfig,
94}
95
96impl Snapshot {
97    /// Create a new snapshot tester
98    pub fn new() -> Self {
99        Self {
100            config: SnapshotConfig::default(),
101        }
102    }
103
104    /// Create with custom config
105    pub fn with_config(config: SnapshotConfig) -> Self {
106        Self { config }
107    }
108
109    /// Set config
110    pub fn config(mut self, config: SnapshotConfig) -> Self {
111        self.config = config;
112        self
113    }
114
115    /// Render a view to a buffer
116    pub fn render_view<V: View>(&self, view: &V, width: u16, height: u16) -> Buffer {
117        let mut buffer = Buffer::new(width, height);
118        let area = Rect::new(0, 0, width, height);
119        let mut ctx = RenderContext::new(&mut buffer, area);
120        view.render(&mut ctx);
121        buffer
122    }
123
124    /// Convert buffer to string representation
125    pub fn buffer_to_string(&self, buffer: &Buffer) -> String {
126        let mut lines = Vec::new();
127
128        for y in 0..buffer.height() {
129            let mut line = String::new();
130            for x in 0..buffer.width() {
131                if let Some(cell) = buffer.get(x, y) {
132                    if self.config.include_colors {
133                        if let Some(fg) = cell.fg {
134                            line.push_str(&format!("\x1b[38;2;{};{};{}m", fg.r, fg.g, fg.b));
135                        }
136                        if let Some(bg) = cell.bg {
137                            line.push_str(&format!("\x1b[48;2;{};{};{}m", bg.r, bg.g, bg.b));
138                        }
139                    }
140                    if self.config.include_modifiers {
141                        if cell.modifier.contains(crate::render::Modifier::BOLD) {
142                            line.push_str("\x1b[1m");
143                        }
144                        if cell.modifier.contains(crate::render::Modifier::ITALIC) {
145                            line.push_str("\x1b[3m");
146                        }
147                        if cell.modifier.contains(crate::render::Modifier::UNDERLINE) {
148                            line.push_str("\x1b[4m");
149                        }
150                    }
151                    line.push(cell.symbol);
152                    if self.config.include_colors || self.config.include_modifiers {
153                        line.push_str("\x1b[0m");
154                    }
155                } else {
156                    line.push(' ');
157                }
158            }
159            // Trim trailing spaces but keep line structure
160            let trimmed = line.trim_end();
161            lines.push(trimmed.to_string());
162        }
163
164        // Remove trailing empty lines
165        while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
166            lines.pop();
167        }
168
169        lines.join("\n")
170    }
171
172    /// Get snapshot file path
173    fn snapshot_path(&self, name: &str) -> PathBuf {
174        self.config.snapshot_dir.join(format!("{}.snap", name))
175    }
176
177    /// Assert snapshot matches
178    pub fn assert_snapshot<V: View>(
179        &self,
180        name: &str,
181        view: &V,
182        width: u16,
183        height: u16,
184    ) -> SnapshotResult {
185        let buffer = self.render_view(view, width, height);
186        let actual = self.buffer_to_string(&buffer);
187        self.assert_snapshot_string(name, &actual)
188    }
189
190    /// Assert snapshot string matches
191    pub fn assert_snapshot_string(&self, name: &str, actual: &str) -> SnapshotResult {
192        let path = self.snapshot_path(name);
193
194        // Ensure directory exists
195        if let Some(parent) = path.parent() {
196            let _ = fs::create_dir_all(parent);
197        }
198
199        // Read existing snapshot
200        let expected = fs::read_to_string(&path).ok();
201
202        match expected {
203            Some(expected) if expected == *actual => SnapshotResult::Match,
204            Some(_) if self.config.update_snapshots => {
205                fs::write(&path, actual).ok();
206                SnapshotResult::Created
207            }
208            Some(expected) => {
209                let diff = self.compute_diff(&expected, actual);
210                SnapshotResult::Mismatch {
211                    expected,
212                    actual: actual.to_string(),
213                    diff,
214                }
215            }
216            None if self.config.update_snapshots => {
217                fs::write(&path, actual).ok();
218                SnapshotResult::Created
219            }
220            None => {
221                fs::write(&path, actual).ok();
222                SnapshotResult::Created
223            }
224        }
225    }
226
227    /// Compute line-by-line diff
228    fn compute_diff(&self, expected: &str, actual: &str) -> Vec<(usize, String, String)> {
229        let expected_lines: Vec<&str> = expected.lines().collect();
230        let actual_lines: Vec<&str> = actual.lines().collect();
231
232        let mut diff = Vec::new();
233        let max_lines = expected_lines.len().max(actual_lines.len());
234
235        for i in 0..max_lines {
236            let exp = expected_lines.get(i).copied().unwrap_or("");
237            let act = actual_lines.get(i).copied().unwrap_or("");
238
239            if exp != act {
240                diff.push((i + 1, exp.to_string(), act.to_string()));
241            }
242        }
243
244        diff
245    }
246
247    /// Format diff for display
248    pub fn format_diff(result: &SnapshotResult) -> String {
249        match result {
250            SnapshotResult::Match => "Snapshot matches!".to_string(),
251            SnapshotResult::Created => "Snapshot created!".to_string(),
252            SnapshotResult::NotFound => "Snapshot not found!".to_string(),
253            SnapshotResult::Mismatch { diff, .. } => {
254                let mut output = String::from("Snapshot mismatch:\n");
255                for (line, expected, actual) in diff {
256                    output.push_str(&format!(
257                        "Line {}:\n  - {}\n  + {}\n",
258                        line, expected, actual
259                    ));
260                }
261                output
262            }
263        }
264    }
265}
266
267impl Default for Snapshot {
268    fn default() -> Self {
269        Self::new()
270    }
271}
272
273/// Convenience function to create a snapshot tester
274pub fn snapshot() -> Snapshot {
275    Snapshot::new()
276}
277
278/// Assert that a view matches its snapshot
279///
280/// # Panics
281/// Panics if the snapshot doesn't match and UPDATE_SNAPSHOTS is not set
282#[macro_export]
283macro_rules! assert_snapshot {
284    ($name:expr, $view:expr) => {
285        assert_snapshot!($name, $view, 80, 24)
286    };
287    ($name:expr, $view:expr, $width:expr, $height:expr) => {{
288        let snap = $crate::app::snapshot::snapshot();
289        let result = snap.assert_snapshot($name, $view, $width, $height);
290        if result.is_mismatch() {
291            panic!(
292                "Snapshot '{}' mismatch!\n{}",
293                $name,
294                $crate::app::snapshot::Snapshot::format_diff(&result)
295            );
296        }
297    }};
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::widget::Text;
304
305    #[test]
306    fn test_snapshot_config_default() {
307        let config = SnapshotConfig::default();
308        assert!(!config.include_colors);
309        assert!(!config.include_modifiers);
310    }
311
312    #[test]
313    fn test_snapshot_config_builder() {
314        let config = SnapshotConfig::default()
315            .snapshot_dir("test_snapshots")
316            .include_colors(true)
317            .include_modifiers(true);
318
319        assert!(config.include_colors);
320        assert!(config.include_modifiers);
321    }
322
323    #[test]
324    fn test_snapshot_new() {
325        let snap = Snapshot::new();
326        assert!(!snap.config.include_colors);
327    }
328
329    #[test]
330    fn test_render_view() {
331        let snap = Snapshot::new();
332        let text = Text::new("Hello");
333        let buffer = snap.render_view(&text, 10, 3);
334
335        assert_eq!(buffer.width(), 10);
336        assert_eq!(buffer.height(), 3);
337    }
338
339    #[test]
340    fn test_buffer_to_string() {
341        let snap = Snapshot::new();
342        let text = Text::new("Test");
343        let buffer = snap.render_view(&text, 10, 1);
344        let output = snap.buffer_to_string(&buffer);
345
346        assert!(output.contains("Test"));
347    }
348
349    #[test]
350    fn test_snapshot_result_is_match() {
351        assert!(SnapshotResult::Match.is_match());
352        assert!(SnapshotResult::Created.is_match());
353        assert!(!SnapshotResult::NotFound.is_match());
354    }
355
356    #[test]
357    fn test_snapshot_result_is_mismatch() {
358        let mismatch = SnapshotResult::Mismatch {
359            expected: "a".to_string(),
360            actual: "b".to_string(),
361            diff: vec![(1, "a".to_string(), "b".to_string())],
362        };
363        assert!(mismatch.is_mismatch());
364        assert!(!SnapshotResult::Match.is_mismatch());
365    }
366
367    #[test]
368    fn test_compute_diff() {
369        let snap = Snapshot::new();
370        let diff = snap.compute_diff("line1\nline2", "line1\nchanged");
371
372        assert_eq!(diff.len(), 1);
373        assert_eq!(diff[0].0, 2);
374        assert_eq!(diff[0].1, "line2");
375        assert_eq!(diff[0].2, "changed");
376    }
377
378    #[test]
379    fn test_format_diff_match() {
380        let output = Snapshot::format_diff(&SnapshotResult::Match);
381        assert!(output.contains("matches"));
382    }
383
384    #[test]
385    fn test_format_diff_mismatch() {
386        let result = SnapshotResult::Mismatch {
387            expected: "a".to_string(),
388            actual: "b".to_string(),
389            diff: vec![(1, "a".to_string(), "b".to_string())],
390        };
391        let output = Snapshot::format_diff(&result);
392        assert!(output.contains("mismatch"));
393        assert!(output.contains("Line 1"));
394    }
395
396    #[test]
397    fn test_snapshot_helper() {
398        let snap = snapshot();
399        // Verify snapshot helper returns valid instance
400        assert!(snap.config.snapshot_dir.as_os_str().len() > 0);
401    }
402}