1use super::backend::TuiFrame;
14use crate::result::{ProbarError, ProbarResult};
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17use std::fs;
18use std::path::{Path, PathBuf};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct TuiSnapshot {
23 pub name: String,
25 pub hash: String,
27 pub width: u16,
29 pub height: u16,
31 pub content: Vec<String>,
33 #[serde(default)]
35 pub metadata: std::collections::HashMap<String, String>,
36}
37
38impl TuiSnapshot {
39 #[must_use]
41 pub fn from_frame(name: &str, frame: &TuiFrame) -> Self {
42 let content: Vec<String> = frame.lines().iter().map(ToString::to_string).collect();
43 let hash = Self::compute_hash(&content);
44
45 Self {
46 name: name.to_string(),
47 hash,
48 width: frame.width(),
49 height: frame.height(),
50 content,
51 metadata: std::collections::HashMap::new(),
52 }
53 }
54
55 #[must_use]
57 pub fn from_lines(name: &str, lines: &[&str]) -> Self {
58 let content: Vec<String> = lines.iter().map(|s| (*s).to_string()).collect();
59 let hash = Self::compute_hash(&content);
60 let width = content.iter().map(|l| l.len()).max().unwrap_or(0) as u16;
61 let height = content.len() as u16;
62
63 Self {
64 name: name.to_string(),
65 hash,
66 width,
67 height,
68 content,
69 metadata: std::collections::HashMap::new(),
70 }
71 }
72
73 #[must_use]
75 pub fn with_metadata(mut self, key: &str, value: &str) -> Self {
76 self.metadata.insert(key.to_string(), value.to_string());
77 self
78 }
79
80 fn compute_hash(content: &[String]) -> String {
82 let mut hasher = Sha256::new();
83 for line in content {
84 hasher.update(line.as_bytes());
85 hasher.update(b"\n");
86 }
87 let result = hasher.finalize();
88 format!("{result:x}")
89 }
90
91 #[must_use]
93 pub fn matches(&self, other: &TuiSnapshot) -> bool {
94 self.hash == other.hash
95 }
96
97 #[must_use]
99 pub fn to_frame(&self) -> TuiFrame {
100 let lines: Vec<&str> = self.content.iter().map(String::as_str).collect();
101 TuiFrame::from_lines(&lines)
102 }
103
104 pub fn save(&self, path: &Path) -> ProbarResult<()> {
106 let yaml = serde_yaml_ng::to_string(self).map_err(|e| {
107 ProbarError::SnapshotSerializationError {
108 message: format!("Failed to serialize snapshot: {e}"),
109 }
110 })?;
111
112 if let Some(parent) = path.parent() {
113 fs::create_dir_all(parent)?;
114 }
115
116 fs::write(path, yaml)?;
117 Ok(())
118 }
119
120 pub fn load(path: &Path) -> ProbarResult<Self> {
122 let yaml = fs::read_to_string(path)?;
123 let snapshot: TuiSnapshot = serde_yaml_ng::from_str(&yaml).map_err(|e| {
124 ProbarError::SnapshotSerializationError {
125 message: format!("Failed to deserialize snapshot: {e}"),
126 }
127 })?;
128 Ok(snapshot)
129 }
130
131 pub fn assert_matches(&self, expected: &TuiSnapshot) -> ProbarResult<()> {
133 if self.matches(expected) {
134 Ok(())
135 } else {
136 let actual_frame = self.to_frame();
137 let expected_frame = expected.to_frame();
138 let diff = actual_frame.diff(&expected_frame);
139
140 Err(ProbarError::AssertionFailed {
141 message: format!(
142 "Snapshot '{}' does not match expected:\n{}",
143 self.name, diff
144 ),
145 })
146 }
147 }
148}
149
150#[derive(Debug)]
152pub struct SnapshotManager {
153 snapshot_dir: PathBuf,
155 update_mode: bool,
157}
158
159impl SnapshotManager {
160 #[must_use]
162 pub fn new(snapshot_dir: &Path) -> Self {
163 Self {
164 snapshot_dir: snapshot_dir.to_path_buf(),
165 update_mode: false,
166 }
167 }
168
169 #[must_use]
171 pub fn with_update_mode(mut self, update: bool) -> Self {
172 self.update_mode = update;
173 self
174 }
175
176 #[must_use]
178 pub fn snapshot_dir(&self) -> &Path {
179 &self.snapshot_dir
180 }
181
182 #[must_use]
184 pub fn snapshot_path(&self, name: &str) -> PathBuf {
185 self.snapshot_dir.join(format!("{name}.snap.yaml"))
186 }
187
188 #[must_use]
190 pub fn exists(&self, name: &str) -> bool {
191 self.snapshot_path(name).exists()
192 }
193
194 pub fn save(&self, snapshot: &TuiSnapshot) -> ProbarResult<()> {
196 let path = self.snapshot_path(&snapshot.name);
197 snapshot.save(&path)
198 }
199
200 pub fn load(&self, name: &str) -> ProbarResult<TuiSnapshot> {
202 let path = self.snapshot_path(name);
203 TuiSnapshot::load(&path)
204 }
205
206 pub fn assert_snapshot(&self, name: &str, frame: &TuiFrame) -> ProbarResult<()> {
208 let actual = TuiSnapshot::from_frame(name, frame);
209 let path = self.snapshot_path(name);
210
211 if path.exists() {
212 let expected = TuiSnapshot::load(&path)?;
213
214 if actual.matches(&expected) {
215 Ok(())
216 } else if self.update_mode {
217 actual.save(&path)?;
218 Ok(())
219 } else {
220 actual.assert_matches(&expected)
221 }
222 } else {
223 actual.save(&path)?;
225 Ok(())
226 }
227 }
228
229 pub fn list(&self) -> ProbarResult<Vec<String>> {
231 if !self.snapshot_dir.exists() {
232 return Ok(Vec::new());
233 }
234
235 let mut names = Vec::new();
236 for entry in fs::read_dir(&self.snapshot_dir)? {
237 let entry = entry?;
238 let path = entry.path();
239 if path.extension().and_then(|s| s.to_str()) == Some("yaml") {
240 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
241 if let Some(name) = stem.strip_suffix(".snap") {
242 names.push(name.to_string());
243 }
244 }
245 }
246 }
247 names.sort();
248 Ok(names)
249 }
250
251 pub fn delete(&self, name: &str) -> ProbarResult<()> {
253 let path = self.snapshot_path(name);
254 if path.exists() {
255 fs::remove_file(path)?;
256 }
257 Ok(())
258 }
259}
260
261impl Default for SnapshotManager {
262 fn default() -> Self {
263 Self::new(Path::new("__tui_snapshots__"))
264 }
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct FrameSequence {
270 pub name: String,
272 pub frames: Vec<TuiSnapshot>,
274 pub duration_ms: u64,
276}
277
278impl FrameSequence {
279 #[must_use]
281 pub fn new(name: &str) -> Self {
282 Self {
283 name: name.to_string(),
284 frames: Vec::new(),
285 duration_ms: 0,
286 }
287 }
288
289 pub fn add_frame(&mut self, frame: &TuiFrame) {
291 let index = self.frames.len();
292 let snapshot = TuiSnapshot::from_frame(&format!("{}_{}", self.name, index), frame);
293 self.frames.push(snapshot);
294 self.duration_ms = frame.timestamp_ms();
295 }
296
297 #[must_use]
299 pub fn len(&self) -> usize {
300 self.frames.len()
301 }
302
303 #[must_use]
305 pub fn is_empty(&self) -> bool {
306 self.frames.is_empty()
307 }
308
309 #[must_use]
311 pub fn frame_at(&self, index: usize) -> Option<&TuiSnapshot> {
312 self.frames.get(index)
313 }
314
315 #[must_use]
317 pub fn first(&self) -> Option<&TuiSnapshot> {
318 self.frames.first()
319 }
320
321 #[must_use]
323 pub fn last(&self) -> Option<&TuiSnapshot> {
324 self.frames.last()
325 }
326
327 #[must_use]
329 pub fn matches(&self, other: &FrameSequence) -> bool {
330 if self.frames.len() != other.frames.len() {
331 return false;
332 }
333 self.frames
334 .iter()
335 .zip(other.frames.iter())
336 .all(|(a, b)| a.matches(b))
337 }
338
339 #[must_use]
341 pub fn diff_frames(&self, other: &FrameSequence) -> Vec<usize> {
342 let mut diffs = Vec::new();
343 let max_len = self.frames.len().max(other.frames.len());
344
345 for i in 0..max_len {
346 let self_frame = self.frames.get(i);
347 let other_frame = other.frames.get(i);
348
349 match (self_frame, other_frame) {
350 (Some(a), Some(b)) if !a.matches(b) => diffs.push(i),
351 (Some(_), None) | (None, Some(_)) => diffs.push(i),
352 _ => {}
353 }
354 }
355 diffs
356 }
357
358 pub fn save(&self, path: &Path) -> ProbarResult<()> {
360 let yaml = serde_yaml_ng::to_string(self).map_err(|e| {
361 ProbarError::SnapshotSerializationError {
362 message: format!("Failed to serialize frame sequence: {e}"),
363 }
364 })?;
365
366 if let Some(parent) = path.parent() {
367 fs::create_dir_all(parent)?;
368 }
369
370 fs::write(path, yaml)?;
371 Ok(())
372 }
373
374 pub fn load(path: &Path) -> ProbarResult<Self> {
376 let yaml = fs::read_to_string(path)?;
377 let sequence: FrameSequence = serde_yaml_ng::from_str(&yaml).map_err(|e| {
378 ProbarError::SnapshotSerializationError {
379 message: format!("Failed to deserialize frame sequence: {e}"),
380 }
381 })?;
382 Ok(sequence)
383 }
384}
385
386#[cfg(test)]
387#[allow(clippy::unwrap_used, clippy::expect_used)]
388mod tests {
389 use super::*;
390 use tempfile::TempDir;
391
392 mod tui_snapshot_tests {
393 use super::*;
394
395 #[test]
396 fn test_from_lines() {
397 let snap = TuiSnapshot::from_lines("test", &["Hello", "World"]);
398 assert_eq!(snap.name, "test");
399 assert_eq!(snap.width, 5);
400 assert_eq!(snap.height, 2);
401 assert_eq!(snap.content, vec!["Hello", "World"]);
402 assert!(!snap.hash.is_empty());
403 }
404
405 #[test]
406 fn test_from_frame() {
407 let frame = TuiFrame::from_lines(&["Test", "Frame"]);
408 let snap = TuiSnapshot::from_frame("test_snap", &frame);
409
410 assert_eq!(snap.name, "test_snap");
411 assert_eq!(snap.width, frame.width());
412 assert_eq!(snap.height, frame.height());
413 }
414
415 #[test]
416 fn test_with_metadata() {
417 let snap = TuiSnapshot::from_lines("test", &["Hello"])
418 .with_metadata("version", "1.0")
419 .with_metadata("author", "test");
420
421 assert_eq!(snap.metadata.get("version"), Some(&"1.0".to_string()));
422 assert_eq!(snap.metadata.get("author"), Some(&"test".to_string()));
423 }
424
425 #[test]
426 fn test_hash_consistency() {
427 let snap1 = TuiSnapshot::from_lines("test1", &["Same", "Content"]);
428 let snap2 = TuiSnapshot::from_lines("test2", &["Same", "Content"]);
429
430 assert_eq!(snap1.hash, snap2.hash);
431 }
432
433 #[test]
434 fn test_hash_different() {
435 let snap1 = TuiSnapshot::from_lines("test", &["Content A"]);
436 let snap2 = TuiSnapshot::from_lines("test", &["Content B"]);
437
438 assert_ne!(snap1.hash, snap2.hash);
439 }
440
441 #[test]
442 fn test_matches() {
443 let snap1 = TuiSnapshot::from_lines("test1", &["Same"]);
444 let snap2 = TuiSnapshot::from_lines("test2", &["Same"]);
445 let snap3 = TuiSnapshot::from_lines("test3", &["Different"]);
446
447 assert!(snap1.matches(&snap2));
448 assert!(!snap1.matches(&snap3));
449 }
450
451 #[test]
452 fn test_to_frame() {
453 let snap = TuiSnapshot::from_lines("test", &["Hello", "World"]);
454 let frame = snap.to_frame();
455
456 assert_eq!(frame.lines(), &["Hello", "World"]);
457 }
458
459 #[test]
460 fn test_save_and_load() {
461 let temp_dir = TempDir::new().unwrap();
462 let path = temp_dir.path().join("test.snap.yaml");
463
464 let snap =
465 TuiSnapshot::from_lines("test", &["Hello", "World"]).with_metadata("key", "value");
466
467 snap.save(&path).unwrap();
468 assert!(path.exists());
469
470 let loaded = TuiSnapshot::load(&path).unwrap();
471 assert_eq!(loaded.name, snap.name);
472 assert_eq!(loaded.hash, snap.hash);
473 assert_eq!(loaded.content, snap.content);
474 assert_eq!(loaded.metadata.get("key"), Some(&"value".to_string()));
475 }
476
477 #[test]
478 fn test_assert_matches_pass() {
479 let snap1 = TuiSnapshot::from_lines("test", &["Same"]);
480 let snap2 = TuiSnapshot::from_lines("test", &["Same"]);
481
482 assert!(snap1.assert_matches(&snap2).is_ok());
483 }
484
485 #[test]
486 fn test_assert_matches_fail() {
487 let snap1 = TuiSnapshot::from_lines("test", &["Actual"]);
488 let snap2 = TuiSnapshot::from_lines("test", &["Expected"]);
489
490 assert!(snap1.assert_matches(&snap2).is_err());
491 }
492 }
493
494 mod snapshot_manager_tests {
495 use super::*;
496
497 #[test]
498 fn test_new() {
499 let temp_dir = TempDir::new().unwrap();
500 let manager = SnapshotManager::new(temp_dir.path());
501
502 assert_eq!(manager.snapshot_dir(), temp_dir.path());
503 }
504
505 #[test]
506 fn test_snapshot_path() {
507 let manager = SnapshotManager::new(Path::new("/tmp/snaps"));
508 let path = manager.snapshot_path("test");
509
510 assert_eq!(path, PathBuf::from("/tmp/snaps/test.snap.yaml"));
511 }
512
513 #[test]
514 fn test_save_and_load() {
515 let temp_dir = TempDir::new().unwrap();
516 let manager = SnapshotManager::new(temp_dir.path());
517
518 let snap = TuiSnapshot::from_lines("test", &["Content"]);
519 manager.save(&snap).unwrap();
520
521 assert!(manager.exists("test"));
522
523 let loaded = manager.load("test").unwrap();
524 assert_eq!(loaded.hash, snap.hash);
525 }
526
527 #[test]
528 fn test_assert_snapshot_create() {
529 let temp_dir = TempDir::new().unwrap();
530 let manager = SnapshotManager::new(temp_dir.path());
531 let frame = TuiFrame::from_lines(&["Initial"]);
532
533 assert!(manager.assert_snapshot("new_snap", &frame).is_ok());
535 assert!(manager.exists("new_snap"));
536 }
537
538 #[test]
539 fn test_assert_snapshot_match() {
540 let temp_dir = TempDir::new().unwrap();
541 let manager = SnapshotManager::new(temp_dir.path());
542 let frame = TuiFrame::from_lines(&["Same Content"]);
543
544 manager.assert_snapshot("test", &frame).unwrap();
546
547 assert!(manager.assert_snapshot("test", &frame).is_ok());
549 }
550
551 #[test]
552 fn test_assert_snapshot_mismatch() {
553 let temp_dir = TempDir::new().unwrap();
554 let manager = SnapshotManager::new(temp_dir.path());
555
556 let frame1 = TuiFrame::from_lines(&["Original"]);
558 manager.assert_snapshot("test", &frame1).unwrap();
559
560 let frame2 = TuiFrame::from_lines(&["Changed"]);
562 assert!(manager.assert_snapshot("test", &frame2).is_err());
563 }
564
565 #[test]
566 fn test_assert_snapshot_update_mode() {
567 let temp_dir = TempDir::new().unwrap();
568 let manager = SnapshotManager::new(temp_dir.path()).with_update_mode(true);
569
570 let frame1 = TuiFrame::from_lines(&["Original"]);
572 manager.assert_snapshot("test", &frame1).unwrap();
573
574 let frame2 = TuiFrame::from_lines(&["Updated"]);
576 assert!(manager.assert_snapshot("test", &frame2).is_ok());
577
578 let loaded = manager.load("test").unwrap();
580 assert_eq!(loaded.content, vec!["Updated"]);
581 }
582
583 #[test]
584 fn test_list() {
585 let temp_dir = TempDir::new().unwrap();
586 let manager = SnapshotManager::new(temp_dir.path());
587
588 manager
589 .save(&TuiSnapshot::from_lines("alpha", &["a"]))
590 .unwrap();
591 manager
592 .save(&TuiSnapshot::from_lines("beta", &["b"]))
593 .unwrap();
594 manager
595 .save(&TuiSnapshot::from_lines("gamma", &["c"]))
596 .unwrap();
597
598 let list = manager.list().unwrap();
599 assert_eq!(list, vec!["alpha", "beta", "gamma"]);
600 }
601
602 #[test]
603 fn test_delete() {
604 let temp_dir = TempDir::new().unwrap();
605 let manager = SnapshotManager::new(temp_dir.path());
606
607 manager
608 .save(&TuiSnapshot::from_lines("test", &["content"]))
609 .unwrap();
610 assert!(manager.exists("test"));
611
612 manager.delete("test").unwrap();
613 assert!(!manager.exists("test"));
614 }
615 }
616
617 mod frame_sequence_tests {
618 use super::*;
619
620 #[test]
621 fn test_new() {
622 let seq = FrameSequence::new("animation");
623 assert_eq!(seq.name, "animation");
624 assert!(seq.is_empty());
625 assert_eq!(seq.len(), 0);
626 }
627
628 #[test]
629 fn test_add_frame() {
630 let mut seq = FrameSequence::new("test");
631 let frame1 = TuiFrame::from_lines(&["Frame 1"]);
632 let frame2 = TuiFrame::from_lines(&["Frame 2"]);
633
634 seq.add_frame(&frame1);
635 seq.add_frame(&frame2);
636
637 assert_eq!(seq.len(), 2);
638 assert!(!seq.is_empty());
639 }
640
641 #[test]
642 fn test_frame_at() {
643 let mut seq = FrameSequence::new("test");
644 seq.add_frame(&TuiFrame::from_lines(&["First"]));
645 seq.add_frame(&TuiFrame::from_lines(&["Second"]));
646
647 assert!(seq.frame_at(0).is_some());
648 assert!(seq.frame_at(1).is_some());
649 assert!(seq.frame_at(2).is_none());
650 }
651
652 #[test]
653 fn test_first_and_last() {
654 let mut seq = FrameSequence::new("test");
655 seq.add_frame(&TuiFrame::from_lines(&["First"]));
656 seq.add_frame(&TuiFrame::from_lines(&["Middle"]));
657 seq.add_frame(&TuiFrame::from_lines(&["Last"]));
658
659 assert_eq!(seq.first().unwrap().content, vec!["First"]);
660 assert_eq!(seq.last().unwrap().content, vec!["Last"]);
661 }
662
663 #[test]
664 fn test_matches() {
665 let mut seq1 = FrameSequence::new("seq1");
666 let mut seq2 = FrameSequence::new("seq2");
667
668 seq1.add_frame(&TuiFrame::from_lines(&["Same"]));
669 seq2.add_frame(&TuiFrame::from_lines(&["Same"]));
670
671 assert!(seq1.matches(&seq2));
672 }
673
674 #[test]
675 fn test_matches_different() {
676 let mut seq1 = FrameSequence::new("seq1");
677 let mut seq2 = FrameSequence::new("seq2");
678
679 seq1.add_frame(&TuiFrame::from_lines(&["A"]));
680 seq2.add_frame(&TuiFrame::from_lines(&["B"]));
681
682 assert!(!seq1.matches(&seq2));
683 }
684
685 #[test]
686 fn test_matches_different_length() {
687 let mut seq1 = FrameSequence::new("seq1");
688 let mut seq2 = FrameSequence::new("seq2");
689
690 seq1.add_frame(&TuiFrame::from_lines(&["A"]));
691 seq1.add_frame(&TuiFrame::from_lines(&["B"]));
692 seq2.add_frame(&TuiFrame::from_lines(&["A"]));
693
694 assert!(!seq1.matches(&seq2));
695 }
696
697 #[test]
698 fn test_diff_frames() {
699 let mut seq1 = FrameSequence::new("seq1");
700 let mut seq2 = FrameSequence::new("seq2");
701
702 seq1.add_frame(&TuiFrame::from_lines(&["Same"]));
703 seq1.add_frame(&TuiFrame::from_lines(&["Diff1"]));
704 seq1.add_frame(&TuiFrame::from_lines(&["Same"]));
705
706 seq2.add_frame(&TuiFrame::from_lines(&["Same"]));
707 seq2.add_frame(&TuiFrame::from_lines(&["Diff2"]));
708 seq2.add_frame(&TuiFrame::from_lines(&["Same"]));
709
710 let diffs = seq1.diff_frames(&seq2);
711 assert_eq!(diffs, vec![1]); }
713
714 #[test]
715 fn test_save_and_load() {
716 let temp_dir = TempDir::new().unwrap();
717 let path = temp_dir.path().join("sequence.yaml");
718
719 let mut seq = FrameSequence::new("test_seq");
720 seq.add_frame(&TuiFrame::from_lines(&["Frame 1"]));
721 seq.add_frame(&TuiFrame::from_lines(&["Frame 2"]));
722
723 seq.save(&path).unwrap();
724 assert!(path.exists());
725
726 let loaded = FrameSequence::load(&path).unwrap();
727 assert_eq!(loaded.name, seq.name);
728 assert_eq!(loaded.len(), seq.len());
729 assert!(loaded.matches(&seq));
730 }
731 }
732}