1use crate::layout::Rect;
4use crate::render::Buffer;
5use crate::widget::{RenderContext, View};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum SnapshotResult {
12 Match,
14 Mismatch {
16 expected: String,
18 actual: String,
20 diff: Vec<(usize, String, String)>,
22 },
23 Created,
25 NotFound,
27}
28
29impl SnapshotResult {
30 pub fn is_match(&self) -> bool {
32 matches!(self, SnapshotResult::Match | SnapshotResult::Created)
33 }
34
35 pub fn is_mismatch(&self) -> bool {
37 matches!(self, SnapshotResult::Mismatch { .. })
38 }
39}
40
41#[derive(Clone, Debug)]
43pub struct SnapshotConfig {
44 pub snapshot_dir: PathBuf,
46 pub update_snapshots: bool,
48 pub include_colors: bool,
50 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 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 pub fn update_snapshots(mut self, update: bool) -> Self {
74 self.update_snapshots = update;
75 self
76 }
77
78 pub fn include_colors(mut self, include: bool) -> Self {
80 self.include_colors = include;
81 self
82 }
83
84 pub fn include_modifiers(mut self, include: bool) -> Self {
86 self.include_modifiers = include;
87 self
88 }
89}
90
91pub struct Snapshot {
93 config: SnapshotConfig,
94}
95
96impl Snapshot {
97 pub fn new() -> Self {
99 Self {
100 config: SnapshotConfig::default(),
101 }
102 }
103
104 pub fn with_config(config: SnapshotConfig) -> Self {
106 Self { config }
107 }
108
109 pub fn config(mut self, config: SnapshotConfig) -> Self {
111 self.config = config;
112 self
113 }
114
115 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 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 let trimmed = line.trim_end();
161 lines.push(trimmed.to_string());
162 }
163
164 while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
166 lines.pop();
167 }
168
169 lines.join("\n")
170 }
171
172 fn snapshot_path(&self, name: &str) -> PathBuf {
174 self.config.snapshot_dir.join(format!("{}.snap", name))
175 }
176
177 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 pub fn assert_snapshot_string(&self, name: &str, actual: &str) -> SnapshotResult {
192 let path = self.snapshot_path(name);
193
194 if let Some(parent) = path.parent() {
196 let _ = fs::create_dir_all(parent);
197 }
198
199 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 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 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
273pub fn snapshot() -> Snapshot {
275 Snapshot::new()
276}
277
278#[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 assert!(snap.config.snapshot_dir.as_os_str().len() > 0);
401 }
402}