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