ratatui-testlib 0.1.0

Integration testing library for terminal user interface applications with Sixel and Bevy support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! Sixel graphics testing support.
//!
//! This module provides functionality for detecting, parsing, and validating
//! Sixel escape sequences in terminal output, with a focus on position tracking
//! and bounds checking.
//!
//! # Overview
//!
//! Sixel is a bitmap graphics format supported by some terminal emulators. This
//! module helps test TUI applications that render Sixel graphics by:
//!
//! - Capturing Sixel sequences from terminal output
//! - Tracking position and dimensions of Sixel graphics
//! - Validating that Sixel graphics appear within expected bounds
//! - Detecting Sixel clearing on screen transitions
//!
//! # Key Types
//!
//! - [`SixelSequence`]: Represents a single Sixel graphic with position/bounds
//! - [`SixelCapture`]: Collection of captured Sixel sequences with query methods
//!
//! # Example
//!
//! ```rust
//! # #[cfg(feature = "sixel")]
//! # {
//! use ratatui_testlib::{TuiTestHarness, ScreenState};
//!
//! # fn test_sixel() -> ratatui_testlib::Result<()> {
//! let mut harness = TuiTestHarness::new(80, 24)?;
//! // ... spawn app and render Sixel graphics ...
//!
//! // Define the preview area where Sixel graphics should appear
//! let preview_area = (5, 5, 30, 15); // (row, col, width, height)
//!
//! // Check that all Sixel graphics are within bounds
//! let regions = harness.state().sixel_regions();
//! for region in regions {
//!     let within_bounds = region.start_row >= preview_area.0
//!         && region.start_col >= preview_area.1
//!         && (region.start_row as u32 + region.height / 6) <= (preview_area.0 as u32 + preview_area.3 as u32)
//!         && (region.start_col as u32 + region.width / 8) <= (preview_area.1 as u32 + preview_area.2 as u32);
//!     assert!(within_bounds, "Sixel at ({}, {}) is outside preview area",
//!         region.start_row, region.start_col);
//! }
//! # Ok(())
//! # }
//! # }
//! ```

use crate::error::{Result, TermTestError};

/// Represents a captured Sixel sequence with position information.
///
/// This is the core type for Sixel testing, tracking where Sixel graphics
/// are rendered on the screen along with their dimensions.
///
/// # Fields
///
/// - `raw`: The raw Sixel escape sequence bytes (including DCS wrapper)
/// - `position`: Cursor position when the Sixel was rendered (row, col) in terminal cells
/// - `bounds`: Calculated bounding rectangle (row, col, width, height) in terminal cells
///
/// # Example
///
/// ```rust
/// use ratatui_testlib::sixel::SixelSequence;
///
/// let seq = SixelSequence::new(
///     vec![/* raw bytes */],
///     (5, 10),           // position
///     (5, 10, 100, 50),  // bounds
/// );
///
/// // Check if within a preview area
/// let preview_area = (0, 0, 200, 100);
/// assert!(seq.is_within(preview_area));
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct SixelSequence {
    /// Raw Sixel escape sequence bytes (including DCS wrapper).
    pub raw: Vec<u8>,
    /// Cursor position when the Sixel was rendered (row, col).
    pub position: (u16, u16),
    /// Calculated bounding rectangle (row, col, width, height).
    pub bounds: (u16, u16, u16, u16),
}

impl SixelSequence {
    /// Creates a new Sixel sequence.
    ///
    /// # Arguments
    ///
    /// * `raw` - Raw escape sequence bytes
    /// * `position` - Cursor position when rendered
    /// * `bounds` - Bounding rectangle (row, col, width, height)
    pub fn new(raw: Vec<u8>, position: (u16, u16), bounds: (u16, u16, u16, u16)) -> Self {
        Self {
            raw,
            position,
            bounds,
        }
    }

    /// Checks if this Sixel is completely within the specified area.
    ///
    /// Returns `true` only if the entire Sixel bounding rectangle fits within
    /// the given area. This is useful for verifying that graphics don't overflow
    /// their designated regions.
    ///
    /// # Arguments
    ///
    /// * `area` - Area as (row, col, width, height)
    ///
    /// # Returns
    ///
    /// `true` if the Sixel is entirely within the area, `false` otherwise.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ratatui_testlib::sixel::SixelSequence;
    ///
    /// let seq = SixelSequence::new(vec![], (5, 5), (5, 5, 10, 10));
    /// let area = (0, 0, 20, 20);
    ///
    /// assert!(seq.is_within(area)); // Completely inside
    ///
    /// let small_area = (0, 0, 10, 10);
    /// assert!(!seq.is_within(small_area)); // Extends beyond
    /// ```
    pub fn is_within(&self, area: (u16, u16, u16, u16)) -> bool {
        let (row, col, width, height) = self.bounds;
        let (area_row, area_col, area_width, area_height) = area;

        row >= area_row
            && col >= area_col
            && (row + height) <= (area_row + area_height)
            && (col + width) <= (area_col + area_width)
    }

    /// Checks if this Sixel overlaps with the specified area.
    ///
    /// Returns `true` if any part of the Sixel bounding rectangle intersects
    /// with the given area. This is useful for detecting unwanted graphics in
    /// certain screen regions.
    ///
    /// # Arguments
    ///
    /// * `area` - Area as (row, col, width, height)
    ///
    /// # Returns
    ///
    /// `true` if the Sixel overlaps with the area, `false` if completely separate.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ratatui_testlib::sixel::SixelSequence;
    ///
    /// let seq = SixelSequence::new(vec![], (5, 5), (5, 5, 10, 10));
    ///
    /// assert!(seq.overlaps((0, 0, 10, 10))); // Partial overlap
    /// assert!(seq.overlaps((10, 10, 10, 10))); // Edge overlap
    /// assert!(!seq.overlaps((0, 0, 5, 5))); // No overlap
    /// ```
    pub fn overlaps(&self, area: (u16, u16, u16, u16)) -> bool {
        let (row, col, width, height) = self.bounds;
        let (area_row, area_col, area_width, area_height) = area;

        !(row + height <= area_row
            || col + width <= area_col
            || row >= area_row + area_height
            || col >= area_col + area_width)
    }
}

/// Captures all Sixel sequences from terminal output.
///
/// This type provides methods for querying and validating Sixel graphics
/// in the terminal screen state. It's the main interface for Sixel testing,
/// offering:
///
/// - Query methods to find Sixel graphics by location
/// - Validation methods to assert correct positioning
/// - Comparison methods to detect Sixel clearing
///
/// # Example
///
/// ```rust
/// # #[cfg(feature = "sixel")]
/// # {
/// use ratatui_testlib::sixel::SixelCapture;
/// use ratatui_testlib::ScreenState;
///
/// # fn test() -> ratatui_testlib::Result<()> {
/// let screen = ScreenState::new(80, 24);
/// let capture = SixelCapture::from_screen_state(&screen);
///
/// // Verify no Sixel graphics outside preview area
/// let preview_area = (5, 5, 30, 20);
/// capture.assert_all_within(preview_area)?;
///
/// // Check for Sixel graphics in specific region
/// let sequences = capture.sequences_in_area(preview_area);
/// println!("Found {} Sixel graphics in preview", sequences.len());
/// # Ok(())
/// # }
/// # }
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct SixelCapture {
    /// All captured Sixel sequences.
    sequences: Vec<SixelSequence>,
}

impl SixelCapture {
    /// Creates a new empty Sixel capture.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ratatui_testlib::sixel::SixelCapture;
    ///
    /// let capture = SixelCapture::new();
    /// assert!(capture.is_empty());
    /// ```
    pub fn new() -> Self {
        Self {
            sequences: Vec::new(),
        }
    }

    /// Creates a Sixel capture from raw terminal output.
    ///
    /// This parses the output and extracts all Sixel sequences with their positions.
    ///
    /// # Arguments
    ///
    /// * `output` - Raw terminal output bytes
    /// * `cursor_positions` - Cursor positions corresponding to each sequence
    ///
    /// # Note
    ///
    /// Phase 1 implementation is a stub. Full Sixel parsing will be implemented
    /// in Phase 3 after validating vt100 capabilities.
    pub fn from_output(_output: &[u8], _cursor_positions: &[(u16, u16)]) -> Self {
        // TODO: Phase 3 - Implement Sixel sequence detection and parsing
        // This requires:
        // 1. Scanning for Sixel escape sequences (ESC P ... ESC \)
        // 2. Parsing Sixel data to extract dimensions
        // 3. Associating cursor positions with sequences
        // 4. Calculating bounding rectangles
        Self::new()
    }

    /// Creates a Sixel capture from a ScreenState.
    ///
    /// This extracts all detected Sixel sequences from the screen state.
    ///
    /// # Arguments
    ///
    /// * `screen` - Reference to the ScreenState containing Sixel information
    pub fn from_screen_state(screen: &crate::screen::ScreenState) -> Self {
        use crate::screen::SixelRegion;

        let sequences = screen.sixel_regions()
            .iter()
            .map(|region: &SixelRegion| {
                // Convert pixel dimensions to terminal cells
                // Standard Sixel-to-cell conversion: 8 pixels/col, 6 pixels/row
                const PIXELS_PER_COL: u32 = 8;
                const PIXELS_PER_ROW: u32 = 6;

                let width_cells = if region.width > 0 {
                    ((region.width + PIXELS_PER_COL - 1) / PIXELS_PER_COL) as u16
                } else {
                    0
                };

                let height_cells = if region.height > 0 {
                    ((region.height + PIXELS_PER_ROW - 1) / PIXELS_PER_ROW) as u16
                } else {
                    0
                };

                SixelSequence::new(
                    region.data.clone(),
                    (region.start_row, region.start_col),
                    (region.start_row, region.start_col, width_cells, height_cells),
                )
            })
            .collect();

        Self { sequences }
    }

    /// Returns all captured sequences.
    ///
    /// # Returns
    ///
    /// A slice containing all Sixel sequences captured from the screen state.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ratatui_testlib::sixel::SixelCapture;
    ///
    /// let capture = SixelCapture::new();
    /// let sequences = capture.sequences();
    /// println!("Captured {} Sixel graphics", sequences.len());
    /// ```
    pub fn sequences(&self) -> &[SixelSequence] {
        &self.sequences
    }

    /// Checks if any Sixel sequences were captured.
    ///
    /// # Returns
    ///
    /// `true` if no Sixel sequences were captured, `false` otherwise.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ratatui_testlib::sixel::SixelCapture;
    ///
    /// let capture = SixelCapture::new();
    /// assert!(capture.is_empty());
    /// ```
    pub fn is_empty(&self) -> bool {
        self.sequences.is_empty()
    }

    /// Returns sequences that are completely within the specified area.
    ///
    /// This filters the captured sequences to only those whose bounding
    /// rectangles are entirely contained within the given area.
    ///
    /// # Arguments
    ///
    /// * `area` - Area as (row, col, width, height)
    ///
    /// # Returns
    ///
    /// A vector of references to sequences within the area.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ratatui_testlib::sixel::SixelCapture;
    ///
    /// let capture = SixelCapture::new();
    /// let preview_area = (5, 5, 30, 20);
    /// let sequences = capture.sequences_in_area(preview_area);
    /// println!("Found {} graphics in preview area", sequences.len());
    /// ```
    pub fn sequences_in_area(&self, area: (u16, u16, u16, u16)) -> Vec<&SixelSequence> {
        self.sequences
            .iter()
            .filter(|seq| seq.is_within(area))
            .collect()
    }

    /// Returns sequences that are not completely within the specified area.
    ///
    /// This is the inverse of [`sequences_in_area`](Self::sequences_in_area).
    /// It returns sequences that extend beyond the area boundaries.
    ///
    /// # Arguments
    ///
    /// * `area` - Area as (row, col, width, height)
    ///
    /// # Returns
    ///
    /// A vector of references to sequences outside or partially outside the area.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ratatui_testlib::sixel::SixelCapture;
    ///
    /// let capture = SixelCapture::new();
    /// let preview_area = (5, 5, 30, 20);
    /// let outside = capture.sequences_outside_area(preview_area);
    /// assert_eq!(outside.len(), 0, "No graphics should be outside preview area");
    /// ```
    pub fn sequences_outside_area(&self, area: (u16, u16, u16, u16)) -> Vec<&SixelSequence> {
        self.sequences
            .iter()
            .filter(|seq| !seq.is_within(area))
            .collect()
    }

    /// Asserts that all Sixel sequences are within the specified area.
    ///
    /// # Arguments
    ///
    /// * `area` - Area as (row, col, width, height)
    ///
    /// # Errors
    ///
    /// Returns an error if any sequence is outside the area.
    pub fn assert_all_within(&self, area: (u16, u16, u16, u16)) -> Result<()> {
        let outside = self.sequences_outside_area(area);
        if !outside.is_empty() {
            return Err(TermTestError::SixelValidation(format!(
                "Found {} Sixel sequence(s) outside area {:?}: {:?}",
                outside.len(),
                area,
                outside.iter().map(|s| s.position).collect::<Vec<_>>()
            )));
        }
        Ok(())
    }

    /// Checks if this capture differs from another.
    ///
    /// This method compares two captures to detect changes in Sixel state,
    /// which is useful for verifying that Sixel graphics are cleared on
    /// screen transitions.
    ///
    /// # Arguments
    ///
    /// * `other` - Other capture to compare with
    ///
    /// # Returns
    ///
    /// `true` if the captures contain different Sixel sequences, `false` if identical.
    ///
    /// # Example
    ///
    /// ```rust
    /// use ratatui_testlib::sixel::SixelCapture;
    /// use ratatui_testlib::ScreenState;
    ///
    /// let screen1 = ScreenState::new(80, 24);
    /// let capture1 = SixelCapture::from_screen_state(&screen1);
    ///
    /// // ... screen transition occurs ...
    ///
    /// let screen2 = ScreenState::new(80, 24);
    /// let capture2 = SixelCapture::from_screen_state(&screen2);
    ///
    /// // Verify Sixel graphics were cleared
    /// if capture1.differs_from(&capture2) {
    ///     println!("Sixel state changed during transition");
    /// }
    /// ```
    pub fn differs_from(&self, other: &SixelCapture) -> bool {
        self.sequences != other.sequences
    }
}

impl Default for SixelCapture {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_sixel_sequence_within() {
        let seq = SixelSequence::new(vec![], (5, 5), (5, 5, 10, 10));
        assert!(seq.is_within((0, 0, 20, 20)));
        assert!(!seq.is_within((0, 0, 10, 10)));
    }

    #[test]
    fn test_sixel_sequence_overlaps() {
        let seq = SixelSequence::new(vec![], (5, 5), (5, 5, 10, 10));
        assert!(seq.overlaps((0, 0, 10, 10)));
        assert!(seq.overlaps((10, 10, 10, 10)));
        assert!(!seq.overlaps((0, 0, 5, 5)));
    }

    #[test]
    fn test_sixel_capture_empty() {
        let capture = SixelCapture::new();
        assert!(capture.is_empty());
        assert_eq!(capture.sequences().len(), 0);
    }

    #[test]
    fn test_sixel_capture_filtering() {
        let mut capture = SixelCapture::new();
        capture.sequences.push(SixelSequence::new(vec![], (5, 5), (5, 5, 10, 10)));
        capture.sequences.push(SixelSequence::new(vec![], (20, 20), (20, 20, 10, 10)));

        let area = (0, 0, 15, 15);
        assert_eq!(capture.sequences_in_area(area).len(), 1);
        assert_eq!(capture.sequences_outside_area(area).len(), 1);
    }
}