Skip to main content

ftui_render/
headless.rs

1#![forbid(unsafe_code)]
2
3//! Headless terminal for CI testing.
4//!
5//! `HeadlessTerm` wraps [`TerminalModel`] to provide a high-level test harness
6//! that works without a real terminal or PTY. It is designed for:
7//!
8//! - **CI environments** where PTY tests are slow or unavailable
9//! - **Snapshot testing** with human-readable diff output
10//! - **Render pipeline verification** by feeding presenter output through
11//!   the terminal model and checking the result
12//!
13//! # Example
14//!
15//! ```
16//! use ftui_render::headless::HeadlessTerm;
17//!
18//! let mut term = HeadlessTerm::new(20, 5);
19//! term.process(b"\x1b[1;1HHello, world!");
20//! assert_eq!(term.row_text(0), "Hello, world!");
21//!
22//! term.assert_matches(&[
23//!     "Hello, world!",
24//!     "",
25//!     "",
26//!     "",
27//!     "",
28//! ]);
29//! ```
30
31use crate::terminal_model::TerminalModel;
32use std::fmt;
33use std::io;
34use std::path::Path;
35
36/// A headless terminal for testing without real terminal I/O.
37///
38/// Processes ANSI escape sequences through [`TerminalModel`] and provides
39/// assertion helpers, snapshot comparison, and export capabilities.
40#[derive(Debug)]
41pub struct HeadlessTerm {
42    model: TerminalModel,
43    captured_output: Vec<u8>,
44}
45
46impl HeadlessTerm {
47    /// Create a new headless terminal with the given dimensions.
48    ///
49    /// # Panics
50    ///
51    /// Panics if width or height is 0.
52    pub fn new(width: u16, height: u16) -> Self {
53        assert!(width > 0, "width must be > 0");
54        assert!(height > 0, "height must be > 0");
55        Self {
56            model: TerminalModel::new(width as usize, height as usize),
57            captured_output: Vec::new(),
58        }
59    }
60
61    /// Terminal width in columns.
62    pub fn width(&self) -> u16 {
63        self.model.width() as u16
64    }
65
66    /// Terminal height in rows.
67    pub fn height(&self) -> u16 {
68        self.model.height() as u16
69    }
70
71    /// Current cursor position as (column, row), 0-indexed.
72    pub fn cursor(&self) -> (u16, u16) {
73        let (x, y) = self.model.cursor();
74        (x as u16, y as u16)
75    }
76
77    /// Process raw bytes through the terminal emulator.
78    ///
79    /// Bytes are parsed as ANSI escape sequences and applied to the
80    /// internal grid, just as a real terminal would.
81    pub fn process(&mut self, bytes: &[u8]) {
82        self.captured_output.extend_from_slice(bytes);
83        self.model.process(bytes);
84    }
85
86    /// Get the text content of a single row, trimmed of trailing spaces.
87    ///
88    /// Returns an empty string for out-of-bounds rows.
89    pub fn row_text(&self, row: usize) -> String {
90        self.model.row_text(row).unwrap_or_default()
91    }
92
93    /// Get all rows as text, trimmed of trailing spaces.
94    pub fn screen_text(&self) -> Vec<String> {
95        (0..self.model.height())
96            .map(|y| self.model.row_text(y).unwrap_or_default())
97            .collect()
98    }
99
100    /// Get all rows as a single string joined by newlines.
101    pub fn screen_string(&self) -> String {
102        self.screen_text().join("\n")
103    }
104
105    /// Access the underlying `TerminalModel` for advanced queries.
106    #[must_use]
107    pub fn model(&self) -> &TerminalModel {
108        &self.model
109    }
110
111    /// Access all captured output bytes (everything passed to `process`).
112    #[must_use]
113    pub fn captured_output(&self) -> &[u8] {
114        &self.captured_output
115    }
116
117    /// Reset the terminal to its initial state (blank screen, cursor at origin).
118    pub fn reset(&mut self) {
119        self.model.reset();
120        self.captured_output.clear();
121    }
122
123    // --- Assertion helpers ---
124
125    /// Assert that the screen content matches the expected lines exactly.
126    ///
127    /// Trailing spaces in both actual and expected lines are trimmed before
128    /// comparison. The number of expected lines must match the terminal height.
129    ///
130    /// # Panics
131    ///
132    /// Panics with a human-readable diff if the content doesn't match.
133    pub fn assert_matches(&self, expected: &[&str]) {
134        let actual = self.screen_text();
135
136        assert_eq!(
137            actual.len(),
138            expected.len(),
139            "HeadlessTerm: line count mismatch: got {} lines, expected {} lines\n\
140             Hint: expected slice length must equal terminal height ({})",
141            actual.len(),
142            expected.len(),
143            self.height(),
144        );
145
146        let mismatches: Vec<LineDiff> = actual
147            .iter()
148            .zip(expected.iter())
149            .enumerate()
150            .filter_map(|(i, (got, want))| {
151                let want_trimmed = want.trim_end();
152                if got.as_str() != want_trimmed {
153                    Some(LineDiff {
154                        line: i,
155                        got: got.clone(),
156                        want: want_trimmed.to_string(),
157                    })
158                } else {
159                    None
160                }
161            })
162            .collect();
163
164        assert!(
165            mismatches.is_empty(),
166            "HeadlessTerm: screen content mismatch\n{}",
167            format_diff(&mismatches)
168        );
169    }
170
171    /// Assert that a specific row matches the expected text.
172    ///
173    /// Trailing spaces are trimmed before comparison.
174    ///
175    /// # Panics
176    ///
177    /// Panics if the row content doesn't match.
178    pub fn assert_row(&self, row: usize, expected: &str) {
179        let actual = self.row_text(row);
180        let expected_trimmed = expected.trim_end();
181        assert_eq!(
182            actual, expected_trimmed,
183            "HeadlessTerm: row {row} mismatch\n  got:  {actual:?}\n  want: {expected_trimmed:?}",
184        );
185    }
186
187    /// Assert that the cursor is at the expected position (column, row), 0-indexed.
188    ///
189    /// # Panics
190    ///
191    /// Panics if the cursor position doesn't match.
192    pub fn assert_cursor(&self, col: u16, row: u16) {
193        let (actual_col, actual_row) = self.cursor();
194        assert_eq!(
195            (actual_col, actual_row),
196            (col, row),
197            "HeadlessTerm: cursor position mismatch\n  got:  ({actual_col}, {actual_row})\n  want: ({col}, {row})",
198        );
199    }
200
201    /// Compare screen content with expected lines and return the diff.
202    ///
203    /// Returns `None` if the content matches exactly.
204    pub fn diff(&self, expected: &[&str]) -> Option<ScreenDiff> {
205        let actual = self.screen_text();
206        let mismatches: Vec<LineDiff> = actual
207            .iter()
208            .zip(expected.iter())
209            .enumerate()
210            .filter_map(|(i, (got, want))| {
211                let want_trimmed = want.trim_end();
212                if got.as_str() != want_trimmed {
213                    Some(LineDiff {
214                        line: i,
215                        got: got.clone(),
216                        want: want_trimmed.to_string(),
217                    })
218                } else {
219                    None
220                }
221            })
222            .collect();
223
224        let line_count_mismatch = actual.len() != expected.len();
225
226        if mismatches.is_empty() && !line_count_mismatch {
227            None
228        } else {
229            Some(ScreenDiff {
230                actual_lines: actual.len(),
231                expected_lines: expected.len(),
232                mismatches,
233            })
234        }
235    }
236
237    // --- Export ---
238
239    /// Export the screen content to a file for debugging.
240    ///
241    /// Writes a human-readable text representation including:
242    /// - Terminal dimensions
243    /// - Cursor position
244    /// - Screen content (with line numbers)
245    /// - Captured output size
246    pub fn export(&self, path: &Path) -> io::Result<()> {
247        use std::io::Write;
248        let mut file = std::fs::File::create(path)?;
249
250        writeln!(file, "=== HeadlessTerm Export ===")?;
251        writeln!(file, "Size: {}x{}", self.width(), self.height())?;
252        let (cx, cy) = self.cursor();
253        writeln!(file, "Cursor: ({cx}, {cy})")?;
254        writeln!(
255            file,
256            "Captured output: {} bytes",
257            self.captured_output.len()
258        )?;
259        writeln!(file)?;
260        writeln!(file, "--- Screen Content ---")?;
261
262        for y in 0..self.model.height() {
263            let text = self.row_text(y);
264            writeln!(file, "{y:3}| {text}")?;
265        }
266
267        writeln!(file)?;
268        writeln!(file, "--- ANSI Dump ---")?;
269        writeln!(
270            file,
271            "{}",
272            TerminalModel::dump_sequences(&self.captured_output)
273        )?;
274
275        Ok(())
276    }
277
278    /// Export the screen content as a formatted string (for inline debugging).
279    pub fn export_string(&self) -> String {
280        let mut out = String::new();
281        out.push_str(&format!("{}x{}", self.width(), self.height()));
282        let (cx, cy) = self.cursor();
283        out.push_str(&format!(" cursor=({cx},{cy})\n"));
284
285        for y in 0..self.model.height() {
286            let text = self.row_text(y);
287            out.push_str(&format!("{y:3}| {text}\n"));
288        }
289        out
290    }
291}
292
293/// A single line difference in a screen comparison.
294#[derive(Debug, Clone)]
295pub struct LineDiff {
296    /// 0-based line index.
297    pub line: usize,
298    /// Actual content.
299    pub got: String,
300    /// Expected content.
301    pub want: String,
302}
303
304/// Result of comparing screen content with expected lines.
305#[derive(Debug, Clone)]
306pub struct ScreenDiff {
307    /// Number of lines in the actual screen.
308    pub actual_lines: usize,
309    /// Number of lines in the expected slice.
310    pub expected_lines: usize,
311    /// Per-line mismatches.
312    pub mismatches: Vec<LineDiff>,
313}
314
315impl fmt::Display for ScreenDiff {
316    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317        if self.actual_lines != self.expected_lines {
318            writeln!(
319                f,
320                "Line count: got {}, expected {}",
321                self.actual_lines, self.expected_lines,
322            )?;
323        }
324        write!(f, "{}", format_diff(&self.mismatches))
325    }
326}
327
328fn format_diff(mismatches: &[LineDiff]) -> String {
329    let mut out = String::new();
330    for d in mismatches {
331        out.push_str(&format!("  line {}:\n", d.line));
332        out.push_str(&format!("    got:  {:?}\n", d.got));
333        out.push_str(&format!("    want: {:?}\n", d.want));
334
335        // Character-level diff hint
336        let diff_col = d.got.chars().zip(d.want.chars()).position(|(a, b)| a != b);
337        if let Some(col) = diff_col {
338            out.push_str(&format!("    first difference at column {col}\n"));
339        } else if d.got.len() != d.want.len() {
340            let shorter = d.got.len().min(d.want.len());
341            out.push_str(&format!(
342                "    diverges at column {shorter} (length difference)\n"
343            ));
344        }
345    }
346    out
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn new_creates_blank_screen() {
355        let term = HeadlessTerm::new(80, 24);
356        assert_eq!(term.width(), 80);
357        assert_eq!(term.height(), 24);
358        assert_eq!(term.cursor(), (0, 0));
359
360        let text = term.screen_text();
361        assert_eq!(text.len(), 24);
362        assert!(text.iter().all(|line| line.is_empty()));
363    }
364
365    #[test]
366    fn process_writes_text() {
367        let mut term = HeadlessTerm::new(20, 5);
368        term.process(b"Hello, world!");
369        assert_eq!(term.row_text(0), "Hello, world!");
370        assert_eq!(term.cursor(), (13, 0));
371    }
372
373    #[test]
374    fn process_cup_and_text() {
375        let mut term = HeadlessTerm::new(20, 5);
376        term.process(b"\x1b[2;3HTest"); // Row 2, Col 3 (1-indexed)
377        assert_eq!(term.row_text(1), "  Test");
378        assert_eq!(term.cursor(), (6, 1));
379    }
380
381    #[test]
382    fn screen_text_returns_all_rows() {
383        let mut term = HeadlessTerm::new(10, 3);
384        term.process(b"\x1b[1;1HLine 1");
385        term.process(b"\x1b[2;1HLine 2");
386        term.process(b"\x1b[3;1HLine 3");
387
388        let text = term.screen_text();
389        assert_eq!(text, vec!["Line 1", "Line 2", "Line 3"]);
390    }
391
392    #[test]
393    fn screen_string_joins_with_newlines() {
394        let mut term = HeadlessTerm::new(10, 3);
395        term.process(b"\x1b[1;1HAB");
396        term.process(b"\x1b[2;1HCD");
397
398        assert_eq!(term.screen_string(), "AB\nCD\n");
399    }
400
401    #[test]
402    fn assert_matches_passes_on_match() {
403        let mut term = HeadlessTerm::new(10, 3);
404        term.process(b"\x1b[1;1HHello");
405        term.process(b"\x1b[3;1HWorld");
406
407        term.assert_matches(&["Hello", "", "World"]);
408    }
409
410    #[test]
411    #[should_panic(expected = "screen content mismatch")]
412    fn assert_matches_panics_on_mismatch() {
413        let mut term = HeadlessTerm::new(10, 3);
414        term.process(b"Hello");
415
416        term.assert_matches(&["Wrong", "", ""]);
417    }
418
419    #[test]
420    #[should_panic(expected = "line count mismatch")]
421    fn assert_matches_panics_on_wrong_line_count() {
422        let term = HeadlessTerm::new(10, 3);
423        term.assert_matches(&["", ""]); // 2 lines for 3-row terminal
424    }
425
426    #[test]
427    fn assert_row_passes_on_match() {
428        let mut term = HeadlessTerm::new(10, 3);
429        term.process(b"Hello");
430        term.assert_row(0, "Hello");
431    }
432
433    #[test]
434    #[should_panic(expected = "row 0 mismatch")]
435    fn assert_row_panics_on_mismatch() {
436        let mut term = HeadlessTerm::new(10, 3);
437        term.process(b"Hello");
438        term.assert_row(0, "World");
439    }
440
441    #[test]
442    fn assert_cursor_passes_on_match() {
443        let mut term = HeadlessTerm::new(20, 5);
444        term.process(b"\x1b[3;5H");
445        term.assert_cursor(4, 2); // 0-indexed
446    }
447
448    #[test]
449    #[should_panic(expected = "cursor position mismatch")]
450    fn assert_cursor_panics_on_mismatch() {
451        let term = HeadlessTerm::new(20, 5);
452        term.assert_cursor(5, 5);
453    }
454
455    #[test]
456    fn diff_returns_none_on_match() {
457        let mut term = HeadlessTerm::new(10, 2);
458        term.process(b"AB");
459        assert!(term.diff(&["AB", ""]).is_none());
460    }
461
462    #[test]
463    fn diff_returns_mismatches() {
464        let mut term = HeadlessTerm::new(10, 3);
465        term.process(b"\x1b[1;1HHello");
466        term.process(b"\x1b[3;1HWorld");
467
468        let diff = term.diff(&["Hello", "X", "World"]).unwrap();
469        assert_eq!(diff.mismatches.len(), 1);
470        assert_eq!(diff.mismatches[0].line, 1);
471        assert_eq!(diff.mismatches[0].got, "");
472        assert_eq!(diff.mismatches[0].want, "X");
473    }
474
475    #[test]
476    fn diff_detects_character_difference() {
477        let mut term = HeadlessTerm::new(10, 1);
478        term.process(b"ABCXEF");
479
480        let diff = term.diff(&["ABCDEF"]).unwrap();
481        assert_eq!(diff.mismatches[0].line, 0);
482    }
483
484    #[test]
485    fn reset_clears_everything() {
486        let mut term = HeadlessTerm::new(10, 3);
487        term.process(b"Hello");
488        term.reset();
489
490        assert_eq!(term.cursor(), (0, 0));
491        assert!(term.captured_output().is_empty());
492        assert!(term.screen_text().iter().all(|l| l.is_empty()));
493    }
494
495    #[test]
496    fn captured_output_records_all_bytes() {
497        let mut term = HeadlessTerm::new(10, 3);
498        term.process(b"\x1b[1mHello");
499        term.process(b"\x1b[0m");
500
501        assert_eq!(term.captured_output(), b"\x1b[1mHello\x1b[0m");
502    }
503
504    #[test]
505    fn export_string_contains_dimensions_and_content() {
506        let mut term = HeadlessTerm::new(10, 3);
507        term.process(b"Test");
508
509        let export = term.export_string();
510        assert!(export.contains("10x3"));
511        assert!(export.contains("Test"));
512    }
513
514    #[test]
515    fn export_to_file() {
516        use std::time::{SystemTime, UNIX_EPOCH};
517        // Use unique directory name to prevent race conditions in parallel tests
518        // Combine timestamp with thread ID for guaranteed uniqueness
519        let timestamp = SystemTime::now()
520            .duration_since(UNIX_EPOCH)
521            .map(|d| d.as_nanos())
522            .unwrap_or(0);
523        let thread_id = format!("{:?}", std::thread::current().id());
524        let dir = std::env::temp_dir().join(format!("ftui_headless_test_{timestamp}_{thread_id}"));
525        std::fs::create_dir_all(&dir).unwrap();
526        let path = dir.join("export_test.txt");
527
528        let mut term = HeadlessTerm::new(20, 5);
529        term.process(b"\x1b[1;1HExported content");
530        term.export(&path).unwrap();
531
532        let contents = std::fs::read_to_string(&path).unwrap();
533        assert!(contents.contains("HeadlessTerm Export"));
534        assert!(contents.contains("20x5"));
535        assert!(contents.contains("Exported content"));
536        assert!(contents.contains("ANSI Dump"));
537
538        // Clean up
539        let _ = std::fs::remove_dir_all(&dir);
540    }
541
542    #[test]
543    fn sgr_styling_tracked() {
544        let mut term = HeadlessTerm::new(20, 5);
545        term.process(b"\x1b[1;31mBold Red\x1b[0m");
546
547        // Verify text content
548        assert_eq!(term.row_text(0), "Bold Red");
549
550        // Verify styling via model
551        let cell = term.model().cell(0, 0).unwrap();
552        assert!(cell.attrs.has_flag(crate::cell::StyleFlags::BOLD));
553    }
554
555    #[test]
556    fn multiline_content() {
557        let mut term = HeadlessTerm::new(20, 5);
558        term.process(b"Line 1\r\nLine 2\r\nLine 3");
559
560        term.assert_matches(&["Line 1", "Line 2", "Line 3", "", ""]);
561    }
562
563    #[test]
564    fn erase_operations_work() {
565        let mut term = HeadlessTerm::new(10, 3);
566        term.process(b"XXXXXXXXXX");
567        term.process(b"\x1b[1;1H"); // Cursor to top-left
568        term.process(b"\x1b[2J"); // Erase entire screen
569
570        term.assert_matches(&["", "", ""]);
571    }
572
573    #[test]
574    fn line_wrap_at_boundary() {
575        let mut term = HeadlessTerm::new(5, 3);
576        term.process(b"ABCDEFGH");
577
578        assert_eq!(term.row_text(0), "ABCDE");
579        assert_eq!(term.row_text(1), "FGH");
580    }
581
582    #[test]
583    fn hyperlink_tracking() {
584        let mut term = HeadlessTerm::new(20, 5);
585        term.process(b"\x1b]8;;https://example.com\x07Link\x1b]8;;\x07");
586
587        assert_eq!(term.row_text(0), "Link");
588        assert!(!term.model().has_dangling_link());
589    }
590
591    #[test]
592    fn screen_diff_display_format() {
593        let diff = ScreenDiff {
594            actual_lines: 3,
595            expected_lines: 3,
596            mismatches: vec![LineDiff {
597                line: 1,
598                got: "actual".to_string(),
599                want: "expected".to_string(),
600            }],
601        };
602
603        let display = format!("{diff}");
604        assert!(display.contains("line 1"));
605        assert!(display.contains("actual"));
606        assert!(display.contains("expected"));
607    }
608
609    #[test]
610    fn format_diff_shows_column_of_first_difference() {
611        let diffs = vec![LineDiff {
612            line: 0,
613            got: "ABCXEF".to_string(),
614            want: "ABCDEF".to_string(),
615        }];
616
617        let formatted = format_diff(&diffs);
618        assert!(formatted.contains("first difference at column 3"));
619    }
620
621    #[test]
622    fn format_diff_shows_length_difference() {
623        let diffs = vec![LineDiff {
624            line: 0,
625            got: "ABC".to_string(),
626            want: "ABCDEF".to_string(),
627        }];
628
629        let formatted = format_diff(&diffs);
630        assert!(formatted.contains("diverges at column 3"));
631    }
632
633    // --- Integration with presenter pipeline ---
634
635    #[test]
636    fn presenter_output_roundtrips() {
637        use crate::buffer::Buffer;
638        use crate::cell::Cell;
639        use crate::diff::BufferDiff;
640        use crate::presenter::{Presenter, TerminalCapabilities};
641
642        // Create two buffers simulating a frame update
643        let prev = Buffer::new(10, 3);
644        let mut next = Buffer::new(10, 3);
645
646        // Write "Hello" on line 0 of the next buffer
647        for (i, ch) in "Hello".chars().enumerate() {
648            next.set(i as u16, 0, Cell::from_char(ch));
649        }
650
651        // Compute diff
652        let diff = BufferDiff::compute(&prev, &next);
653
654        // Emit ANSI via presenter into a Vec<u8>
655        let output = {
656            let mut buf = Vec::new();
657            let caps = TerminalCapabilities::default();
658            let mut presenter = Presenter::new(&mut buf, caps);
659            presenter.present(&next, &diff).unwrap();
660            drop(presenter); // flush on drop
661            buf
662        };
663
664        // Feed the output into HeadlessTerm
665        let mut term = HeadlessTerm::new(10, 3);
666        term.process(&output);
667
668        // Verify the round-trip
669        term.assert_row(0, "Hello");
670    }
671
672    #[test]
673    fn presenter_incremental_update_roundtrips() {
674        use crate::buffer::Buffer;
675        use crate::cell::Cell;
676        use crate::diff::BufferDiff;
677        use crate::presenter::{Presenter, TerminalCapabilities};
678
679        let mut term = HeadlessTerm::new(10, 3);
680
681        // Frame 1: write "Hello"
682        let prev = Buffer::new(10, 3);
683        let mut next = Buffer::new(10, 3);
684        for (i, ch) in "Hello".chars().enumerate() {
685            next.set(i as u16, 0, Cell::from_char(ch));
686        }
687
688        let diff = BufferDiff::compute(&prev, &next);
689        let output = {
690            let mut buf = Vec::new();
691            let caps = TerminalCapabilities::default();
692            let mut presenter = Presenter::new(&mut buf, caps);
693            presenter.present(&next, &diff).unwrap();
694            drop(presenter);
695            buf
696        };
697        term.process(&output);
698        term.assert_row(0, "Hello");
699
700        // Frame 2: change "Hello" to "World"
701        let prev2 = next;
702        let mut next2 = Buffer::new(10, 3);
703        for (i, ch) in "World".chars().enumerate() {
704            next2.set(i as u16, 0, Cell::from_char(ch));
705        }
706
707        let diff2 = BufferDiff::compute(&prev2, &next2);
708        let output2 = {
709            let mut buf = Vec::new();
710            let caps = TerminalCapabilities::default();
711            let mut presenter = Presenter::new(&mut buf, caps);
712            presenter.present(&next2, &diff2).unwrap();
713            drop(presenter);
714            buf
715        };
716        term.process(&output2);
717        term.assert_row(0, "World");
718    }
719
720    // --- Cursor direction movement (CSI A/B/C/D) ---
721
722    #[test]
723    fn cursor_move_up() {
724        let mut term = HeadlessTerm::new(20, 10);
725        term.process(b"\x1b[5;5H"); // Row 5, Col 5 (1-indexed) → (4, 4) 0-indexed
726        term.assert_cursor(4, 4);
727        term.process(b"\x1b[2A"); // Move up 2
728        term.assert_cursor(4, 2);
729    }
730
731    #[test]
732    fn cursor_move_down() {
733        let mut term = HeadlessTerm::new(20, 10);
734        term.process(b"\x1b[1;1H"); // Top-left
735        term.assert_cursor(0, 0);
736        term.process(b"\x1b[3B"); // Move down 3
737        term.assert_cursor(0, 3);
738    }
739
740    #[test]
741    fn cursor_move_forward() {
742        let mut term = HeadlessTerm::new(20, 10);
743        term.process(b"\x1b[1;1H"); // Top-left
744        term.assert_cursor(0, 0);
745        term.process(b"\x1b[5C"); // Move right 5
746        term.assert_cursor(5, 0);
747    }
748
749    #[test]
750    fn cursor_move_back() {
751        let mut term = HeadlessTerm::new(20, 10);
752        term.process(b"\x1b[1;10H"); // Row 1, Col 10 → (9, 0) 0-indexed
753        term.assert_cursor(9, 0);
754        term.process(b"\x1b[4D"); // Move left 4
755        term.assert_cursor(5, 0);
756    }
757
758    #[test]
759    fn cursor_move_default_count() {
760        // When no count is given, CSI A/B/C/D default to 1
761        let mut term = HeadlessTerm::new(20, 10);
762        term.process(b"\x1b[5;5H"); // → (4, 4)
763        term.process(b"\x1b[A"); // Up 1
764        term.assert_cursor(4, 3);
765        term.process(b"\x1b[C"); // Right 1
766        term.assert_cursor(5, 3);
767        term.process(b"\x1b[B"); // Down 1
768        term.assert_cursor(5, 4);
769        term.process(b"\x1b[D"); // Left 1
770        term.assert_cursor(4, 4);
771    }
772
773    #[test]
774    fn cursor_multiple_directions() {
775        let mut term = HeadlessTerm::new(20, 10);
776        term.process(b"\x1b[1;1H"); // Start at origin
777        term.process(b"\x1b[3C"); // Right 3
778        term.process(b"\x1b[2B"); // Down 2
779        term.process(b"\x1b[1D"); // Left 1
780        term.process(b"\x1b[1A"); // Up 1
781        term.assert_cursor(2, 1);
782    }
783
784    #[test]
785    fn cursor_clamped_at_top() {
786        let mut term = HeadlessTerm::new(20, 10);
787        term.process(b"\x1b[1;1H"); // Top-left
788        term.process(b"\x1b[99A"); // Try to go up 99 from row 0
789        term.assert_cursor(0, 0); // Should stay at top
790    }
791
792    #[test]
793    fn cursor_clamped_at_left() {
794        let mut term = HeadlessTerm::new(20, 10);
795        term.process(b"\x1b[1;1H"); // Top-left
796        term.process(b"\x1b[99D"); // Try to go left 99 from col 0
797        term.assert_cursor(0, 0); // Should stay at left
798    }
799
800    #[test]
801    fn cursor_clamped_at_bottom() {
802        let mut term = HeadlessTerm::new(20, 10);
803        term.process(b"\x1b[10;1H"); // Last row (1-indexed)
804        term.process(b"\x1b[99B"); // Try to go down 99
805        let (_, row) = term.cursor();
806        assert!(row <= 9, "cursor row {row} should be <= 9 (height - 1)");
807    }
808
809    #[test]
810    fn cursor_clamped_at_right() {
811        let mut term = HeadlessTerm::new(20, 10);
812        term.process(b"\x1b[1;20H"); // Last column (1-indexed)
813        term.process(b"\x1b[99C"); // Try to go right 99
814        let (col, _) = term.cursor();
815        assert!(col <= 19, "cursor col {col} should be <= 19 (width - 1)");
816    }
817
818    #[test]
819    fn cursor_absolute_column_cha() {
820        let mut term = HeadlessTerm::new(20, 10);
821        term.process(b"\x1b[3;1H"); // Row 3
822        term.process(b"\x1b[8G"); // CHA: set column to 8 (1-indexed → col 7)
823        term.assert_cursor(7, 2);
824    }
825
826    #[test]
827    fn cursor_absolute_row_vpa() {
828        let mut term = HeadlessTerm::new(20, 10);
829        term.process(b"\x1b[1;5H"); // Col 5
830        term.process(b"\x1b[6d"); // VPA: set row to 6 (1-indexed → row 5)
831        term.assert_cursor(4, 5);
832    }
833
834    #[test]
835    fn cursor_move_then_write() {
836        let mut term = HeadlessTerm::new(20, 5);
837        term.process(b"\x1b[3;1H"); // Move to row 3 col 1
838        term.process(b"ABC");
839        term.process(b"\x1b[2A"); // Up 2
840        term.process(b"XY");
841        term.assert_row(0, "   XY");
842        term.assert_row(2, "ABC");
843    }
844}