Skip to main content

jugar_probar/tui/
snapshot.rs

1//! TUI Snapshot Testing (Feature 21 - EDD Compliance)
2//!
3//! Provides snapshot testing for TUI frames with YAML serialization.
4//!
5//! ## EXTREME TDD: Tests written FIRST per spec
6//!
7//! ## Toyota Way Application
8//!
9//! - **Poka-Yoke**: Content-addressable snapshots prevent mismatches
10//! - **Muda**: YAML format for human-readable diffs
11//! - **Genchi Genbutsu**: Snapshot files are source of truth
12
13use 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/// TUI Snapshot for golden file testing
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct TuiSnapshot {
23    /// Snapshot name/identifier
24    pub name: String,
25    /// Content hash for quick comparison
26    pub hash: String,
27    /// Frame width
28    pub width: u16,
29    /// Frame height
30    pub height: u16,
31    /// Frame content as lines
32    pub content: Vec<String>,
33    /// Optional metadata
34    #[serde(default)]
35    pub metadata: std::collections::HashMap<String, String>,
36}
37
38impl TuiSnapshot {
39    /// Create a snapshot from a TUI frame
40    #[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    /// Create a snapshot from raw lines
56    #[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    /// Add metadata to the snapshot
74    #[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    /// Compute content hash
81    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    /// Check if this snapshot matches another
92    #[must_use]
93    pub fn matches(&self, other: &TuiSnapshot) -> bool {
94        self.hash == other.hash
95    }
96
97    /// Convert to TUI frame
98    #[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    /// Save snapshot to a YAML file
105    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    /// Load snapshot from a YAML file
121    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    /// Assert this snapshot matches an expected snapshot
132    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/// Snapshot manager for organizing and comparing snapshots
151#[derive(Debug)]
152pub struct SnapshotManager {
153    /// Directory for snapshot files
154    snapshot_dir: PathBuf,
155    /// Whether to update snapshots on mismatch
156    update_mode: bool,
157}
158
159impl SnapshotManager {
160    /// Create a new snapshot manager
161    #[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    /// Enable update mode (overwrite snapshots on mismatch)
170    #[must_use]
171    pub fn with_update_mode(mut self, update: bool) -> Self {
172        self.update_mode = update;
173        self
174    }
175
176    /// Get the snapshot directory
177    #[must_use]
178    pub fn snapshot_dir(&self) -> &Path {
179        &self.snapshot_dir
180    }
181
182    /// Get the path for a named snapshot
183    #[must_use]
184    pub fn snapshot_path(&self, name: &str) -> PathBuf {
185        self.snapshot_dir.join(format!("{name}.snap.yaml"))
186    }
187
188    /// Check if a snapshot exists
189    #[must_use]
190    pub fn exists(&self, name: &str) -> bool {
191        self.snapshot_path(name).exists()
192    }
193
194    /// Save a snapshot
195    pub fn save(&self, snapshot: &TuiSnapshot) -> ProbarResult<()> {
196        let path = self.snapshot_path(&snapshot.name);
197        snapshot.save(&path)
198    }
199
200    /// Load a snapshot
201    pub fn load(&self, name: &str) -> ProbarResult<TuiSnapshot> {
202        let path = self.snapshot_path(name);
203        TuiSnapshot::load(&path)
204    }
205
206    /// Assert a frame matches a snapshot (or create if missing)
207    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            // First run - create the snapshot
224            actual.save(&path)?;
225            Ok(())
226        }
227    }
228
229    /// List all snapshots in the directory
230    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    /// Delete a snapshot
252    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/// Sequence of frames for animation testing
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct FrameSequence {
270    /// Sequence name
271    pub name: String,
272    /// Ordered list of frame snapshots
273    pub frames: Vec<TuiSnapshot>,
274    /// Total duration in milliseconds
275    pub duration_ms: u64,
276}
277
278impl FrameSequence {
279    /// Create a new frame sequence
280    #[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    /// Add a frame to the sequence
290    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    /// Get the number of frames
298    #[must_use]
299    pub fn len(&self) -> usize {
300        self.frames.len()
301    }
302
303    /// Check if sequence is empty
304    #[must_use]
305    pub fn is_empty(&self) -> bool {
306        self.frames.is_empty()
307    }
308
309    /// Get frame at index
310    #[must_use]
311    pub fn frame_at(&self, index: usize) -> Option<&TuiSnapshot> {
312        self.frames.get(index)
313    }
314
315    /// Get the first frame
316    #[must_use]
317    pub fn first(&self) -> Option<&TuiSnapshot> {
318        self.frames.first()
319    }
320
321    /// Get the last frame
322    #[must_use]
323    pub fn last(&self) -> Option<&TuiSnapshot> {
324        self.frames.last()
325    }
326
327    /// Check if all frames in sequence match another sequence
328    #[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    /// Get frames that differ between sequences
340    #[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    /// Save sequence to YAML file
359    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    /// Load sequence from YAML file
375    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            // First call creates the snapshot
534            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            // Create snapshot
545            manager.assert_snapshot("test", &frame).unwrap();
546
547            // Assert same content matches
548            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            // Create with one content
557            let frame1 = TuiFrame::from_lines(&["Original"]);
558            manager.assert_snapshot("test", &frame1).unwrap();
559
560            // Assert different content fails
561            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            // Create with one content
571            let frame1 = TuiFrame::from_lines(&["Original"]);
572            manager.assert_snapshot("test", &frame1).unwrap();
573
574            // Update mode allows different content
575            let frame2 = TuiFrame::from_lines(&["Updated"]);
576            assert!(manager.assert_snapshot("test", &frame2).is_ok());
577
578            // Verify it was updated
579            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]); // Only frame 1 differs
712        }
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}