Skip to main content

jugar_probar/tui/
backend.rs

1//! TUI Test Backend for Frame Capture
2//!
3//! Provides a test backend that captures frames for assertion.
4//! Uses TextGrid instead of presentar-terminal CellBuffer for zero external dependencies.
5//!
6//! ## EXTREME TDD: Tests written FIRST per spec
7
8use super::buffer::TextGrid;
9use crate::result::{ProbarError, ProbarResult};
10use serde::{Deserialize, Serialize};
11use std::fmt;
12
13/// A captured TUI frame for testing
14#[derive(Clone, Serialize, Deserialize)]
15pub struct TuiFrame {
16    /// The buffer content
17    content: Vec<String>,
18    /// Frame width
19    width: u16,
20    /// Frame height
21    height: u16,
22    /// Timestamp when captured (milliseconds from test start)
23    timestamp_ms: u64,
24}
25
26impl TuiFrame {
27    /// Create a new TUI frame from a TextGrid
28    #[must_use]
29    pub fn from_grid(grid: &TextGrid, timestamp_ms: u64) -> Self {
30        Self {
31            content: grid.to_lines(),
32            width: grid.width(),
33            height: grid.height(),
34            timestamp_ms,
35        }
36    }
37
38    /// Create a frame from raw text lines
39    #[must_use]
40    pub fn from_lines(lines: &[&str]) -> Self {
41        let height = lines.len() as u16;
42        // Count characters, not bytes (important for Unicode TUI content)
43        let width = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16;
44        let content = lines.iter().map(|s| (*s).to_string()).collect();
45
46        Self {
47            content,
48            width,
49            height,
50            timestamp_ms: 0,
51        }
52    }
53
54    /// Get the frame width
55    #[must_use]
56    pub fn width(&self) -> u16 {
57        self.width
58    }
59
60    /// Get the frame height
61    #[must_use]
62    pub fn height(&self) -> u16 {
63        self.height
64    }
65
66    /// Get the timestamp
67    #[must_use]
68    pub fn timestamp_ms(&self) -> u64 {
69        self.timestamp_ms
70    }
71
72    /// Get the frame content as lines
73    #[must_use]
74    pub fn lines(&self) -> &[String] {
75        &self.content
76    }
77
78    /// Get the full frame content as a single string
79    #[must_use]
80    pub fn as_text(&self) -> String {
81        self.content.join("\n")
82    }
83
84    /// Check if the frame contains a substring
85    #[must_use]
86    pub fn contains(&self, text: &str) -> bool {
87        self.content.iter().any(|line| line.contains(text))
88    }
89
90    /// Check if the frame matches a regex pattern
91    #[must_use]
92    pub fn matches(&self, pattern: &str) -> ProbarResult<bool> {
93        let re = regex::Regex::new(pattern).map_err(|e| ProbarError::TuiError {
94            message: format!("Invalid regex pattern: {e}"),
95        })?;
96        Ok(self.content.iter().any(|line| re.is_match(line)))
97    }
98
99    /// Find all lines matching a pattern
100    #[must_use]
101    pub fn find_matches(&self, pattern: &str) -> ProbarResult<Vec<&str>> {
102        let re = regex::Regex::new(pattern).map_err(|e| ProbarError::TuiError {
103            message: format!("Invalid regex pattern: {e}"),
104        })?;
105        Ok(self
106            .content
107            .iter()
108            .filter(|line| re.is_match(line))
109            .map(String::as_str)
110            .collect())
111    }
112
113    /// Get a specific line by index
114    #[must_use]
115    pub fn line(&self, index: usize) -> Option<&str> {
116        self.content.get(index).map(String::as_str)
117    }
118
119    /// Check if two frames are identical
120    #[must_use]
121    pub fn is_identical(&self, other: &TuiFrame) -> bool {
122        self.content == other.content
123    }
124
125    /// Get the difference between two frames
126    #[must_use]
127    pub fn diff(&self, other: &TuiFrame) -> FrameDiff {
128        let mut changed_lines = Vec::new();
129
130        let max_lines = self.content.len().max(other.content.len());
131        for i in 0..max_lines {
132            let self_line = self.content.get(i).map(String::as_str).unwrap_or("");
133            let other_line = other.content.get(i).map(String::as_str).unwrap_or("");
134
135            if self_line != other_line {
136                changed_lines.push(LineDiff {
137                    line_number: i,
138                    expected: self_line.to_string(),
139                    actual: other_line.to_string(),
140                });
141            }
142        }
143
144        FrameDiff {
145            is_identical: changed_lines.is_empty(),
146            changed_lines,
147        }
148    }
149}
150
151impl fmt::Debug for TuiFrame {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        writeln!(f, "TuiFrame({}x{}):", self.width, self.height)?;
154        for (i, line) in self.content.iter().enumerate() {
155            writeln!(f, "  {i:3}: {line}")?;
156        }
157        Ok(())
158    }
159}
160
161/// Difference between two frames
162#[derive(Debug, Clone)]
163pub struct FrameDiff {
164    /// Whether frames are identical
165    pub is_identical: bool,
166    /// Lines that differ
167    pub changed_lines: Vec<LineDiff>,
168}
169
170/// A single line difference
171#[derive(Debug, Clone)]
172pub struct LineDiff {
173    /// Line number (0-indexed)
174    pub line_number: usize,
175    /// Expected content
176    pub expected: String,
177    /// Actual content
178    pub actual: String,
179}
180
181impl fmt::Display for FrameDiff {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        if self.is_identical {
184            write!(f, "Frames are identical")
185        } else {
186            writeln!(f, "Frame differences:")?;
187            for diff in &self.changed_lines {
188                writeln!(f, "  Line {}: ", diff.line_number)?;
189                writeln!(f, "    Expected: {:?}", diff.expected)?;
190                writeln!(f, "    Actual:   {:?}", diff.actual)?;
191            }
192            Ok(())
193        }
194    }
195}
196
197/// TUI Test Backend for capturing frames
198///
199/// Provides a text grid and frame capture functionality for testing terminal UIs.
200/// Uses TextGrid instead of presentar-terminal CellBuffer for zero external dependencies.
201#[derive(Debug)]
202pub struct TuiTestBackend {
203    grid: TextGrid,
204    frames: Vec<TuiFrame>,
205    start_time: std::time::Instant,
206}
207
208impl TuiTestBackend {
209    /// Create a new test backend with the given dimensions
210    #[must_use]
211    pub fn new(width: u16, height: u16) -> Self {
212        Self {
213            grid: TextGrid::new(width, height),
214            frames: Vec::new(),
215            start_time: std::time::Instant::now(),
216        }
217    }
218
219    /// Get the backend dimensions
220    #[must_use]
221    pub fn size(&self) -> (u16, u16) {
222        (self.grid.width(), self.grid.height())
223    }
224
225    /// Get a reference to the underlying grid
226    #[must_use]
227    pub fn grid(&self) -> &TextGrid {
228        &self.grid
229    }
230
231    /// Get a mutable reference to the underlying grid
232    #[must_use]
233    pub fn grid_mut(&mut self) -> &mut TextGrid {
234        &mut self.grid
235    }
236
237    /// Capture the current frame
238    pub fn capture_frame(&mut self) -> TuiFrame {
239        let timestamp = self.start_time.elapsed().as_millis() as u64;
240        let frame = TuiFrame::from_grid(&self.grid, timestamp);
241        self.frames.push(frame.clone());
242        frame
243    }
244
245    /// Get the current frame without storing it
246    #[must_use]
247    pub fn current_frame(&self) -> TuiFrame {
248        let timestamp = self.start_time.elapsed().as_millis() as u64;
249        TuiFrame::from_grid(&self.grid, timestamp)
250    }
251
252    /// Get all captured frames
253    #[must_use]
254    pub fn frames(&self) -> &[TuiFrame] {
255        &self.frames
256    }
257
258    /// Get the number of captured frames
259    #[must_use]
260    pub fn frame_count(&self) -> usize {
261        self.frames.len()
262    }
263
264    /// Clear the grid
265    pub fn clear(&mut self) {
266        self.grid.clear();
267    }
268
269    /// Reset the backend (clear grid and frames)
270    pub fn reset(&mut self) {
271        self.grid.clear();
272        self.frames.clear();
273        self.start_time = std::time::Instant::now();
274    }
275
276    /// Resize the backend
277    pub fn resize(&mut self, width: u16, height: u16) {
278        self.grid.resize(width, height);
279    }
280
281    /// Write text at a position (for testing)
282    pub fn write_text(&mut self, x: u16, y: u16, text: &str) {
283        self.grid.write_str(x, y, text);
284    }
285
286    /// Write multiple lines starting at a position
287    pub fn write_lines(&mut self, x: u16, y: u16, lines: &[&str]) {
288        for (i, line) in lines.iter().enumerate() {
289            self.grid.write_str(x, y + i as u16, line);
290        }
291    }
292}
293
294impl Default for TuiTestBackend {
295    fn default() -> Self {
296        Self::new(80, 24) // Standard terminal size
297    }
298}
299
300#[cfg(test)]
301#[allow(clippy::unwrap_used, clippy::expect_used)]
302mod tests {
303    use super::*;
304
305    mod tui_frame_tests {
306        use super::*;
307
308        #[test]
309        fn test_from_lines() {
310            let frame = TuiFrame::from_lines(&["Hello", "World"]);
311            assert_eq!(frame.width(), 5);
312            assert_eq!(frame.height(), 2);
313            assert_eq!(frame.lines(), &["Hello", "World"]);
314        }
315
316        #[test]
317        fn test_from_grid() {
318            let mut grid = TextGrid::new(10, 3);
319            grid.write_str(0, 0, "Hello");
320            grid.write_str(0, 1, "World");
321
322            let frame = TuiFrame::from_grid(&grid, 100);
323            assert_eq!(frame.width(), 10);
324            assert_eq!(frame.height(), 3);
325            assert_eq!(frame.timestamp_ms(), 100);
326            assert!(frame.contains("Hello"));
327            assert!(frame.contains("World"));
328        }
329
330        #[test]
331        fn test_as_text() {
332            let frame = TuiFrame::from_lines(&["Line 1", "Line 2"]);
333            assert_eq!(frame.as_text(), "Line 1\nLine 2");
334        }
335
336        #[test]
337        fn test_contains() {
338            let frame = TuiFrame::from_lines(&["Hello World", "Goodbye"]);
339            assert!(frame.contains("World"));
340            assert!(frame.contains("Goodbye"));
341            assert!(!frame.contains("Missing"));
342        }
343
344        #[test]
345        fn test_matches_regex() {
346            let frame = TuiFrame::from_lines(&["Score: 100", "Lives: 3"]);
347            assert!(frame.matches(r"Score: \d+").unwrap());
348            assert!(frame.matches(r"Lives: \d").unwrap());
349            assert!(!frame.matches(r"Health: \d+").unwrap());
350        }
351
352        #[test]
353        fn test_find_matches() {
354            let frame = TuiFrame::from_lines(&["Error: failed", "Warning: slow", "Info: ok"]);
355            let errors = frame.find_matches(r"Error:.*").unwrap();
356            assert_eq!(errors.len(), 1);
357            assert_eq!(errors[0], "Error: failed");
358        }
359
360        #[test]
361        fn test_line_access() {
362            let frame = TuiFrame::from_lines(&["First", "Second", "Third"]);
363            assert_eq!(frame.line(0), Some("First"));
364            assert_eq!(frame.line(1), Some("Second"));
365            assert_eq!(frame.line(2), Some("Third"));
366            assert_eq!(frame.line(3), None);
367        }
368
369        #[test]
370        fn test_is_identical() {
371            let frame1 = TuiFrame::from_lines(&["Same", "Content"]);
372            let frame2 = TuiFrame::from_lines(&["Same", "Content"]);
373            let frame3 = TuiFrame::from_lines(&["Different", "Content"]);
374
375            assert!(frame1.is_identical(&frame2));
376            assert!(!frame1.is_identical(&frame3));
377        }
378
379        #[test]
380        fn test_diff() {
381            let frame1 = TuiFrame::from_lines(&["Same", "Different1"]);
382            let frame2 = TuiFrame::from_lines(&["Same", "Different2"]);
383
384            let diff = frame1.diff(&frame2);
385            assert!(!diff.is_identical);
386            assert_eq!(diff.changed_lines.len(), 1);
387            assert_eq!(diff.changed_lines[0].line_number, 1);
388            assert_eq!(diff.changed_lines[0].expected, "Different1");
389            assert_eq!(diff.changed_lines[0].actual, "Different2");
390        }
391
392        #[test]
393        fn test_diff_identical() {
394            let frame1 = TuiFrame::from_lines(&["Same", "Same"]);
395            let frame2 = TuiFrame::from_lines(&["Same", "Same"]);
396
397            let diff = frame1.diff(&frame2);
398            assert!(diff.is_identical);
399            assert!(diff.changed_lines.is_empty());
400        }
401    }
402
403    mod tui_test_backend_tests {
404        use super::*;
405
406        #[test]
407        fn test_new() {
408            let backend = TuiTestBackend::new(80, 24);
409            assert_eq!(backend.size(), (80, 24));
410            assert_eq!(backend.frame_count(), 0);
411        }
412
413        #[test]
414        fn test_default() {
415            let backend = TuiTestBackend::default();
416            assert_eq!(backend.size(), (80, 24));
417        }
418
419        #[test]
420        fn test_write_text() {
421            let mut backend = TuiTestBackend::new(20, 5);
422            backend.write_text(0, 0, "Hello");
423
424            let frame = backend.current_frame();
425            assert!(frame.contains("Hello"));
426        }
427
428        #[test]
429        fn test_write_lines() {
430            let mut backend = TuiTestBackend::new(20, 5);
431            backend.write_lines(0, 0, &["Line 1", "Line 2"]);
432
433            let frame = backend.current_frame();
434            assert!(frame.contains("Line 1"));
435            assert!(frame.contains("Line 2"));
436        }
437
438        #[test]
439        fn test_capture_frame() {
440            let mut backend = TuiTestBackend::new(20, 5);
441            backend.write_text(0, 0, "Test");
442
443            let frame = backend.capture_frame();
444            assert!(frame.contains("Test"));
445            assert_eq!(backend.frame_count(), 1);
446
447            backend.write_text(0, 1, "More");
448            let _ = backend.capture_frame();
449            assert_eq!(backend.frame_count(), 2);
450        }
451
452        #[test]
453        fn test_frames() {
454            let mut backend = TuiTestBackend::new(20, 5);
455
456            backend.write_text(0, 0, "Frame1");
457            let _ = backend.capture_frame();
458
459            backend.write_text(0, 1, "Frame2");
460            let _ = backend.capture_frame();
461
462            let frames = backend.frames();
463            assert_eq!(frames.len(), 2);
464            assert!(frames[0].contains("Frame1"));
465            assert!(frames[1].contains("Frame2"));
466        }
467
468        #[test]
469        fn test_clear() {
470            let mut backend = TuiTestBackend::new(20, 5);
471            backend.write_text(0, 0, "Hello");
472            backend.clear();
473
474            let frame = backend.current_frame();
475            assert!(!frame.contains("Hello"));
476        }
477
478        #[test]
479        fn test_reset() {
480            let mut backend = TuiTestBackend::new(20, 5);
481            backend.write_text(0, 0, "Hello");
482            let _ = backend.capture_frame();
483
484            backend.reset();
485            assert_eq!(backend.frame_count(), 0);
486            assert!(!backend.current_frame().contains("Hello"));
487        }
488
489        #[test]
490        fn test_resize() {
491            let mut backend = TuiTestBackend::new(20, 5);
492            backend.resize(40, 10);
493            assert_eq!(backend.size(), (40, 10));
494        }
495
496        #[test]
497        fn test_grid_access() {
498            let mut backend = TuiTestBackend::new(20, 5);
499
500            // Test grid() accessor
501            assert_eq!(backend.grid().width(), 20);
502
503            // Test grid_mut() accessor
504            backend.grid_mut().set(0, 0, 'X');
505            assert_eq!(backend.grid().get(0, 0), Some('X'));
506        }
507    }
508}