1use 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#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum SnapshotResult {
14 Match,
16 Mismatch {
18 expected: String,
20 actual: String,
22 diff: Vec<(usize, String, String)>,
24 },
25 Created,
27 NotFound,
29}
30
31impl SnapshotResult {
32 pub fn is_match(&self) -> bool {
34 matches!(self, SnapshotResult::Match | SnapshotResult::Created)
35 }
36
37 pub fn is_mismatch(&self) -> bool {
39 matches!(self, SnapshotResult::Mismatch { .. })
40 }
41}
42
43#[derive(Clone, Debug)]
45pub struct SnapshotConfig {
46 pub snapshot_dir: PathBuf,
48 pub update_snapshots: bool,
50 pub include_colors: bool,
52 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 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 pub fn update_snapshots(mut self, update: bool) -> Self {
76 self.update_snapshots = update;
77 self
78 }
79
80 pub fn include_colors(mut self, include: bool) -> Self {
82 self.include_colors = include;
83 self
84 }
85
86 pub fn include_modifiers(mut self, include: bool) -> Self {
88 self.include_modifiers = include;
89 self
90 }
91}
92
93pub struct Snapshot {
95 config: SnapshotConfig,
96}
97
98impl Snapshot {
99 pub fn new() -> Self {
101 Self {
102 config: SnapshotConfig::default(),
103 }
104 }
105
106 pub fn with_config(config: SnapshotConfig) -> Self {
108 Self { config }
109 }
110
111 pub fn config(mut self, config: SnapshotConfig) -> Self {
113 self.config = config;
114 self
115 }
116
117 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 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("[1m");
145 }
146 if cell.modifier.contains(crate::render::Modifier::ITALIC) {
147 line.push_str("[3m");
148 }
149 if cell.modifier.contains(crate::render::Modifier::UNDERLINE) {
150 line.push_str("[4m");
151 }
152 }
153 line.push(cell.symbol);
154 if self.config.include_colors || self.config.include_modifiers {
155 line.push_str("[0m");
156 }
157 } else {
158 line.push(' ');
159 }
160 }
161 let trimmed = line.trim_end();
163 lines.push(trimmed.to_string());
164 }
165
166 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 fn snapshot_path(&self, name: &str) -> PathBuf {
179 self.config.snapshot_dir.join(format!("{}.snap", name))
180 }
181
182 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 pub fn assert_snapshot_string(&self, name: &str, actual: &str) -> SnapshotResult {
197 let path = self.snapshot_path(name);
198
199 if let Some(parent) = path.parent() {
201 let _ = fs::create_dir_all(parent);
202 }
203
204 let expected = if path.exists() {
206 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 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 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
297pub fn snapshot() -> Snapshot {
299 Snapshot::new()
300}
301
302#[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#[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 assert!(snap.config.snapshot_dir.as_os_str().len() > 0);
427 }
428
429 #[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 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 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 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 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}