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