Skip to main content

jugar_probar/presentar/
terminal.rs

1//! Terminal snapshot assertions for presentar testing.
2//!
3//! Provides cell-based terminal output capture and assertion capabilities.
4
5use std::fmt;
6
7/// RGB color representation.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
9pub struct Color {
10    /// Red component (0-255).
11    pub r: u8,
12    /// Green component (0-255).
13    pub g: u8,
14    /// Blue component (0-255).
15    pub b: u8,
16}
17
18impl Color {
19    /// Create a new color from RGB values.
20    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
21        Self { r, g, b }
22    }
23
24    /// Parse color from hex string (e.g., "#64C8FF").
25    pub fn from_hex(hex: &str) -> Option<Self> {
26        let hex = hex.strip_prefix('#').unwrap_or(hex);
27        if hex.len() != 6 {
28            return None;
29        }
30        let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
31        let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
32        let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
33        Some(Self { r, g, b })
34    }
35
36    /// Convert to hex string.
37    pub fn to_hex(&self) -> String {
38        format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
39    }
40
41    /// Black color.
42    pub const BLACK: Self = Self::rgb(0, 0, 0);
43    /// White color.
44    pub const WHITE: Self = Self::rgb(255, 255, 255);
45    /// Red color.
46    pub const RED: Self = Self::rgb(255, 0, 0);
47    /// Green color.
48    pub const GREEN: Self = Self::rgb(0, 255, 0);
49    /// Blue color.
50    pub const BLUE: Self = Self::rgb(0, 0, 255);
51    /// Cyan color.
52    pub const CYAN: Self = Self::rgb(0, 255, 255);
53    /// Yellow color.
54    pub const YELLOW: Self = Self::rgb(255, 255, 0);
55    /// Magenta color.
56    pub const MAGENTA: Self = Self::rgb(255, 0, 255);
57}
58
59impl fmt::Display for Color {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(f, "{}", self.to_hex())
62    }
63}
64
65/// A single terminal cell.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Cell {
68    /// Character in the cell.
69    pub ch: char,
70    /// Foreground color.
71    pub fg: Color,
72    /// Background color.
73    pub bg: Color,
74    /// Bold attribute.
75    pub bold: bool,
76    /// Underline attribute.
77    pub underline: bool,
78}
79
80impl Default for Cell {
81    fn default() -> Self {
82        Self {
83            ch: ' ',
84            fg: Color::WHITE,
85            bg: Color::BLACK,
86            bold: false,
87            underline: false,
88        }
89    }
90}
91
92impl Cell {
93    /// Create a new cell with a character.
94    pub fn new(ch: char) -> Self {
95        Self {
96            ch,
97            ..Default::default()
98        }
99    }
100
101    /// Set foreground color.
102    pub fn with_fg(mut self, fg: Color) -> Self {
103        self.fg = fg;
104        self
105    }
106
107    /// Set background color.
108    pub fn with_bg(mut self, bg: Color) -> Self {
109        self.bg = bg;
110        self
111    }
112}
113
114/// Terminal snapshot for testing.
115#[derive(Debug, Clone)]
116pub struct TerminalSnapshot {
117    cells: Vec<Cell>,
118    width: u16,
119    height: u16,
120}
121
122impl TerminalSnapshot {
123    /// Create a new empty snapshot.
124    pub fn new(width: u16, height: u16) -> Self {
125        let cells = vec![Cell::default(); (width as usize) * (height as usize)];
126        Self {
127            cells,
128            width,
129            height,
130        }
131    }
132
133    /// Create snapshot from a string (for testing).
134    pub fn from_string(text: &str, width: u16, height: u16) -> Self {
135        let mut snapshot = Self::new(width, height);
136        for (y, line) in text.lines().enumerate() {
137            if y >= height as usize {
138                break;
139            }
140            for (x, ch) in line.chars().enumerate() {
141                if x >= width as usize {
142                    break;
143                }
144                snapshot.set(x as u16, y as u16, Cell::new(ch));
145            }
146        }
147        snapshot
148    }
149
150    /// Get cell at position.
151    pub fn get(&self, x: u16, y: u16) -> Option<&Cell> {
152        if x >= self.width || y >= self.height {
153            return None;
154        }
155        let idx = (y as usize) * (self.width as usize) + (x as usize);
156        self.cells.get(idx)
157    }
158
159    /// Set cell at position.
160    pub fn set(&mut self, x: u16, y: u16, cell: Cell) {
161        if x < self.width && y < self.height {
162            let idx = (y as usize) * (self.width as usize) + (x as usize);
163            self.cells[idx] = cell;
164        }
165    }
166
167    /// Get snapshot dimensions.
168    pub fn dimensions(&self) -> (u16, u16) {
169        (self.width, self.height)
170    }
171
172    /// Convert to string (characters only).
173    pub fn to_text(&self) -> String {
174        let mut result = String::new();
175        for y in 0..self.height {
176            for x in 0..self.width {
177                if let Some(cell) = self.get(x, y) {
178                    result.push(cell.ch);
179                }
180            }
181            result.push('\n');
182        }
183        result
184    }
185
186    /// Check if snapshot contains text.
187    pub fn contains(&self, text: &str) -> bool {
188        self.to_text().contains(text)
189    }
190
191    /// Check if snapshot contains all texts.
192    pub fn contains_all(&self, texts: &[&str]) -> bool {
193        let content = self.to_text();
194        texts.iter().all(|t| content.contains(t))
195    }
196
197    /// Check if snapshot contains any of the texts.
198    pub fn contains_any(&self, texts: &[&str]) -> bool {
199        let content = self.to_text();
200        texts.iter().any(|t| content.contains(t))
201    }
202
203    /// Get foreground color at position.
204    pub fn fg_color_at(&self, x: u16, y: u16) -> Option<Color> {
205        self.get(x, y).map(|c| c.fg)
206    }
207
208    /// Get background color at position.
209    pub fn bg_color_at(&self, x: u16, y: u16) -> Option<Color> {
210        self.get(x, y).map(|c| c.bg)
211    }
212
213    /// Count occurrences of a character.
214    pub fn count_char(&self, ch: char) -> usize {
215        self.cells.iter().filter(|c| c.ch == ch).count()
216    }
217
218    /// Find first occurrence of text, returns (x, y) position.
219    pub fn find(&self, text: &str) -> Option<(u16, u16)> {
220        let content = self.to_text();
221        let pos = content.find(text)?;
222        let line_start = content[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
223        let x = pos - line_start;
224        let y = content[..pos].matches('\n').count();
225        Some((x as u16, y as u16))
226    }
227
228    /// Get a rectangular region as a new snapshot.
229    pub fn region(&self, x: u16, y: u16, width: u16, height: u16) -> Self {
230        let mut result = Self::new(width, height);
231        for dy in 0..height {
232            for dx in 0..width {
233                if let Some(cell) = self.get(x + dx, y + dy) {
234                    result.set(dx, dy, cell.clone());
235                }
236            }
237        }
238        result
239    }
240}
241
242impl fmt::Display for TerminalSnapshot {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        write!(f, "{}", self.to_text())
245    }
246}
247
248/// Terminal assertion types.
249#[derive(Debug, Clone)]
250pub enum TerminalAssertion {
251    /// Assert text is present.
252    Contains(String),
253    /// Assert text is not present.
254    NotContains(String),
255    /// Assert color at position.
256    ColorAt {
257        /// X coordinate.
258        x: u16,
259        /// Y coordinate.
260        y: u16,
261        /// Expected color.
262        expected: Color,
263    },
264    /// Assert character at position.
265    CharAt {
266        /// X coordinate.
267        x: u16,
268        /// Y coordinate.
269        y: u16,
270        /// Expected character.
271        expected: char,
272    },
273    /// Assert region matches text.
274    RegionEquals {
275        /// X coordinate of region origin.
276        x: u16,
277        /// Y coordinate of region origin.
278        y: u16,
279        /// Region width.
280        width: u16,
281        /// Region height.
282        height: u16,
283        /// Expected text content.
284        expected: String,
285    },
286}
287
288impl TerminalAssertion {
289    /// Check assertion against snapshot.
290    pub fn check(&self, snapshot: &TerminalSnapshot) -> Result<(), String> {
291        match self {
292            Self::Contains(text) => {
293                if snapshot.contains(text) {
294                    Ok(())
295                } else {
296                    Err(format!("Expected to contain: {}", text))
297                }
298            }
299            Self::NotContains(text) => {
300                if !snapshot.contains(text) {
301                    Ok(())
302                } else {
303                    Err(format!("Expected not to contain: {}", text))
304                }
305            }
306            Self::ColorAt { x, y, expected } => match snapshot.fg_color_at(*x, *y) {
307                Some(actual) if actual == *expected => Ok(()),
308                Some(actual) => Err(format!(
309                    "Color at ({}, {}): expected {}, got {}",
310                    x, y, expected, actual
311                )),
312                None => Err(format!("Position ({}, {}) out of bounds", x, y)),
313            },
314            Self::CharAt { x, y, expected } => match snapshot.get(*x, *y) {
315                Some(cell) if cell.ch == *expected => Ok(()),
316                Some(cell) => Err(format!(
317                    "Char at ({}, {}): expected '{}', got '{}'",
318                    x, y, expected, cell.ch
319                )),
320                None => Err(format!("Position ({}, {}) out of bounds", x, y)),
321            },
322            Self::RegionEquals {
323                x,
324                y,
325                width,
326                height,
327                expected,
328            } => {
329                let region = snapshot.region(*x, *y, *width, *height);
330                let actual = region.to_text().trim_end().to_string();
331                let expected = expected.trim_end();
332                if actual == expected {
333                    Ok(())
334                } else {
335                    Err(format!(
336                        "Region at ({}, {}) {}x{}: expected\n{}\ngot\n{}",
337                        x, y, width, height, expected, actual
338                    ))
339                }
340            }
341        }
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn test_color_from_hex() {
351        let color = Color::from_hex("#64C8FF").unwrap();
352        assert_eq!(color.r, 100);
353        assert_eq!(color.g, 200);
354        assert_eq!(color.b, 255);
355    }
356
357    #[test]
358    fn test_color_to_hex() {
359        let color = Color::rgb(100, 200, 255);
360        assert_eq!(color.to_hex(), "#64C8FF");
361    }
362
363    #[test]
364    fn test_color_from_hex_invalid() {
365        assert!(Color::from_hex("invalid").is_none());
366        assert!(Color::from_hex("#12345").is_none());
367        assert!(Color::from_hex("#GGGGGG").is_none());
368    }
369
370    #[test]
371    fn test_cell_default() {
372        let cell = Cell::default();
373        assert_eq!(cell.ch, ' ');
374        assert_eq!(cell.fg, Color::WHITE);
375        assert_eq!(cell.bg, Color::BLACK);
376    }
377
378    #[test]
379    fn test_cell_builder() {
380        let cell = Cell::new('A').with_fg(Color::RED).with_bg(Color::BLUE);
381        assert_eq!(cell.ch, 'A');
382        assert_eq!(cell.fg, Color::RED);
383        assert_eq!(cell.bg, Color::BLUE);
384    }
385
386    #[test]
387    fn test_snapshot_new() {
388        let snapshot = TerminalSnapshot::new(80, 24);
389        assert_eq!(snapshot.dimensions(), (80, 24));
390    }
391
392    #[test]
393    fn test_snapshot_from_string() {
394        let snapshot = TerminalSnapshot::from_string("Hello\nWorld", 80, 24);
395        assert!(snapshot.contains("Hello"));
396        assert!(snapshot.contains("World"));
397    }
398
399    #[test]
400    fn test_snapshot_get_set() {
401        let mut snapshot = TerminalSnapshot::new(10, 10);
402        snapshot.set(5, 5, Cell::new('X'));
403        let cell = snapshot.get(5, 5).unwrap();
404        assert_eq!(cell.ch, 'X');
405    }
406
407    #[test]
408    fn test_snapshot_get_out_of_bounds() {
409        let snapshot = TerminalSnapshot::new(10, 10);
410        assert!(snapshot.get(100, 100).is_none());
411    }
412
413    #[test]
414    fn test_snapshot_contains() {
415        let snapshot = TerminalSnapshot::from_string("CPU 45%\nMEM 60%", 80, 24);
416        assert!(snapshot.contains("CPU"));
417        assert!(snapshot.contains("45%"));
418        assert!(!snapshot.contains("GPU"));
419    }
420
421    #[test]
422    fn test_snapshot_contains_all() {
423        let snapshot = TerminalSnapshot::from_string("CPU 45%\nMEM 60%", 80, 24);
424        assert!(snapshot.contains_all(&["CPU", "MEM"]));
425        assert!(!snapshot.contains_all(&["CPU", "GPU"]));
426    }
427
428    #[test]
429    fn test_snapshot_contains_any() {
430        let snapshot = TerminalSnapshot::from_string("CPU 45%", 80, 24);
431        assert!(snapshot.contains_any(&["CPU", "GPU"]));
432        assert!(!snapshot.contains_any(&["GPU", "DISK"]));
433    }
434
435    #[test]
436    fn test_snapshot_find() {
437        let snapshot = TerminalSnapshot::from_string("Hello World", 80, 24);
438        let pos = snapshot.find("World").unwrap();
439        assert_eq!(pos, (6, 0));
440    }
441
442    #[test]
443    fn test_snapshot_count_char() {
444        let snapshot = TerminalSnapshot::from_string("AAABBC", 80, 24);
445        assert_eq!(snapshot.count_char('A'), 3);
446        assert_eq!(snapshot.count_char('B'), 2);
447        assert_eq!(snapshot.count_char('C'), 1);
448    }
449
450    #[test]
451    fn test_snapshot_region() {
452        let snapshot = TerminalSnapshot::from_string("ABCD\nEFGH\nIJKL", 80, 24);
453        let region = snapshot.region(1, 1, 2, 2);
454        assert!(region.contains("FG"));
455    }
456
457    #[test]
458    fn test_assertion_contains() {
459        let snapshot = TerminalSnapshot::from_string("Hello", 80, 24);
460        let assertion = TerminalAssertion::Contains("Hello".into());
461        assert!(assertion.check(&snapshot).is_ok());
462
463        let assertion = TerminalAssertion::Contains("World".into());
464        assert!(assertion.check(&snapshot).is_err());
465    }
466
467    #[test]
468    fn test_assertion_not_contains() {
469        let snapshot = TerminalSnapshot::from_string("Hello", 80, 24);
470        let assertion = TerminalAssertion::NotContains("World".into());
471        assert!(assertion.check(&snapshot).is_ok());
472
473        let assertion = TerminalAssertion::NotContains("Hello".into());
474        assert!(assertion.check(&snapshot).is_err());
475    }
476
477    #[test]
478    fn test_assertion_color_at() {
479        let mut snapshot = TerminalSnapshot::new(10, 10);
480        snapshot.set(5, 5, Cell::new('X').with_fg(Color::RED));
481
482        let assertion = TerminalAssertion::ColorAt {
483            x: 5,
484            y: 5,
485            expected: Color::RED,
486        };
487        assert!(assertion.check(&snapshot).is_ok());
488
489        let assertion = TerminalAssertion::ColorAt {
490            x: 5,
491            y: 5,
492            expected: Color::BLUE,
493        };
494        assert!(assertion.check(&snapshot).is_err());
495    }
496
497    #[test]
498    fn test_assertion_char_at() {
499        let snapshot = TerminalSnapshot::from_string("ABC", 80, 24);
500        let assertion = TerminalAssertion::CharAt {
501            x: 1,
502            y: 0,
503            expected: 'B',
504        };
505        assert!(assertion.check(&snapshot).is_ok());
506    }
507
508    #[test]
509    fn test_color_display() {
510        let color = Color::rgb(100, 200, 255);
511        assert_eq!(format!("{}", color), "#64C8FF");
512    }
513
514    #[test]
515    fn test_snapshot_display() {
516        let snapshot = TerminalSnapshot::from_string("Test", 10, 1);
517        let display = format!("{}", snapshot);
518        assert!(display.contains("Test"));
519    }
520}