Skip to main content

ftui_harness/
terminal_model.rs

1#![forbid(unsafe_code)]
2
3//! Simplified terminal model for testing presenter output.
4//!
5//! Parses ANSI escape sequences and updates an internal cell grid,
6//! enabling verification that presenter output produces the expected
7//! terminal state without needing a real terminal.
8//!
9//! # Supported Sequences
10//! - SGR (Select Graphic Rendition): styles, colors (truecolor)
11//! - CUP (Cursor Position): `CSI row ; col H`
12//! - Cursor movement: `CSI n A/B/C/D`
13//! - EL (Erase Line): `CSI n K`
14//! - ED (Erase Display): `CSI n J`
15//! - OSC 8 (Hyperlinks): open/close
16//! - DEC synchronized output markers (ignored)
17//!
18//! # Example
19//! ```
20//! use ftui_harness::terminal_model::TerminalModel;
21//!
22//! let mut model = TerminalModel::new(80, 24);
23//! model.feed(b"Hello\x1b[1;31m World\x1b[0m");
24//! assert_eq!(model.char_at(0, 0), 'H');
25//! assert_eq!(model.char_at(5, 0), ' ');
26//! assert!(model.style_at(6, 0).bold);
27//! ```
28
29/// RGB color.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub struct Rgb {
32    pub r: u8,
33    pub g: u8,
34    pub b: u8,
35}
36
37impl Rgb {
38    pub const fn new(r: u8, g: u8, b: u8) -> Self {
39        Self { r, g, b }
40    }
41}
42
43/// Style state tracked by the terminal model.
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct ModelStyle {
46    pub fg: Option<Rgb>,
47    pub bg: Option<Rgb>,
48    pub bold: bool,
49    pub dim: bool,
50    pub italic: bool,
51    pub underline: bool,
52    pub blink: bool,
53    pub reverse: bool,
54    pub strikethrough: bool,
55}
56
57/// A single cell in the terminal model.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ModelCell {
60    pub ch: char,
61    pub style: ModelStyle,
62    pub link: Option<String>,
63}
64
65impl Default for ModelCell {
66    fn default() -> Self {
67        Self {
68            ch: ' ',
69            style: ModelStyle::default(),
70            link: None,
71        }
72    }
73}
74
75/// Erase mode for EL/ED sequences.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum EraseMode {
78    ToEnd,
79    ToStart,
80    All,
81}
82
83/// Internal parser state.
84#[derive(Debug, Clone, PartialEq, Eq)]
85enum ParserState {
86    Ground,
87    Escape,
88    Csi,
89    Osc,
90}
91
92/// Simplified terminal model for testing.
93pub struct TerminalModel {
94    grid: Vec<Vec<ModelCell>>,
95    cursor_x: u16,
96    cursor_y: u16,
97    current_style: ModelStyle,
98    current_link: Option<String>,
99    width: u16,
100    height: u16,
101    // Parser state
102    state: ParserState,
103    csi_params: Vec<u16>,
104    csi_current: u16,
105    osc_buffer: Vec<u8>,
106}
107
108impl TerminalModel {
109    /// Create a new terminal model with the given dimensions.
110    pub fn new(width: u16, height: u16) -> Self {
111        let grid = (0..height)
112            .map(|_| (0..width).map(|_| ModelCell::default()).collect())
113            .collect();
114        Self {
115            grid,
116            cursor_x: 0,
117            cursor_y: 0,
118            current_style: ModelStyle::default(),
119            current_link: None,
120            width,
121            height,
122            state: ParserState::Ground,
123            csi_params: Vec::new(),
124            csi_current: 0,
125            osc_buffer: Vec::new(),
126        }
127    }
128
129    /// Terminal width.
130    #[inline]
131    pub fn width(&self) -> u16 {
132        self.width
133    }
134
135    /// Terminal height.
136    #[inline]
137    pub fn height(&self) -> u16 {
138        self.height
139    }
140
141    /// Current cursor position.
142    #[inline]
143    pub fn cursor(&self) -> (u16, u16) {
144        (self.cursor_x, self.cursor_y)
145    }
146
147    /// Get the character at (x, y).
148    pub fn char_at(&self, x: u16, y: u16) -> char {
149        self.cell_at(x, y).map_or(' ', |c| c.ch)
150    }
151
152    /// Get the style at (x, y).
153    pub fn style_at(&self, x: u16, y: u16) -> ModelStyle {
154        self.cell_at(x, y)
155            .map_or_else(ModelStyle::default, |c| c.style.clone())
156    }
157
158    /// Get the link at (x, y).
159    pub fn link_at(&self, x: u16, y: u16) -> Option<String> {
160        self.cell_at(x, y).and_then(|c| c.link.clone())
161    }
162
163    /// Get a cell reference.
164    fn cell_at(&self, x: u16, y: u16) -> Option<&ModelCell> {
165        self.grid
166            .get(y as usize)
167            .and_then(|row| row.get(x as usize))
168    }
169
170    /// Read a row as a string (trailing spaces trimmed).
171    pub fn row_text(&self, y: u16) -> String {
172        if let Some(row) = self.grid.get(y as usize) {
173            let s: String = row.iter().map(|c| c.ch).collect();
174            s.trim_end().to_string()
175        } else {
176            String::new()
177        }
178    }
179
180    /// Read the entire screen as text.
181    pub fn screen_text(&self) -> String {
182        let mut lines: Vec<String> = (0..self.height).map(|y| self.row_text(y)).collect();
183        // Trim trailing empty lines
184        while lines.last().is_some_and(|l| l.is_empty()) {
185            lines.pop();
186        }
187        lines.join("\n")
188    }
189
190    /// Dump model state for debugging.
191    pub fn dump(&self) -> String {
192        let mut out = String::new();
193        for (y, row) in self.grid.iter().enumerate() {
194            out.push_str(&format!("{y:3}| "));
195            for cell in row {
196                out.push(cell.ch);
197            }
198            out.push('\n');
199        }
200        out.push_str(&format!("Cursor: ({}, {})\n", self.cursor_x, self.cursor_y));
201        out.push_str(&format!("Style: {:?}\n", self.current_style));
202        out
203    }
204
205    /// Feed bytes to the terminal model.
206    pub fn feed(&mut self, bytes: &[u8]) {
207        for &byte in bytes {
208            self.advance(byte);
209        }
210    }
211
212    /// Feed a string to the terminal model.
213    pub fn feed_str(&mut self, s: &str) {
214        self.feed(s.as_bytes());
215    }
216
217    fn advance(&mut self, byte: u8) {
218        match self.state {
219            ParserState::Ground => self.ground(byte),
220            ParserState::Escape => self.escape(byte),
221            ParserState::Csi => self.csi(byte),
222            ParserState::Osc => self.osc(byte),
223        }
224    }
225
226    fn ground(&mut self, byte: u8) {
227        match byte {
228            0x1b => {
229                self.state = ParserState::Escape;
230            }
231            0x0a => {
232                // LF: move cursor down
233                if self.cursor_y + 1 < self.height {
234                    self.cursor_y += 1;
235                }
236            }
237            0x0d => {
238                // CR: move cursor to column 0
239                self.cursor_x = 0;
240            }
241            0x08 => {
242                // BS: move cursor left
243                self.cursor_x = self.cursor_x.saturating_sub(1);
244            }
245            0x09 => {
246                // TAB: advance to next 8-column tab stop
247                self.cursor_x = ((self.cursor_x / 8) + 1) * 8;
248                if self.cursor_x >= self.width {
249                    self.cursor_x = self.width.saturating_sub(1);
250                }
251            }
252            0x20..=0x7e => {
253                self.put_char(byte as char);
254            }
255            0xc0..=0xff => {
256                // Start of multi-byte UTF-8 - simplified: just treat as '?'
257                self.put_char('?');
258            }
259            _ => {
260                // Ignore other control chars
261            }
262        }
263    }
264
265    fn escape(&mut self, byte: u8) {
266        match byte {
267            b'[' => {
268                self.state = ParserState::Csi;
269                self.csi_params.clear();
270                self.csi_current = 0;
271            }
272            b']' => {
273                self.state = ParserState::Osc;
274                self.osc_buffer.clear();
275            }
276            _ => {
277                // Unknown escape, return to ground
278                self.state = ParserState::Ground;
279            }
280        }
281    }
282
283    fn csi(&mut self, byte: u8) {
284        match byte {
285            b'0'..=b'9' => {
286                self.csi_current = self.csi_current.saturating_mul(10) + (byte - b'0') as u16;
287            }
288            b';' => {
289                self.csi_params.push(self.csi_current);
290                self.csi_current = 0;
291            }
292            b'?' => {
293                // Private mode prefix - ignore
294            }
295            b'A' => {
296                self.csi_params.push(self.csi_current);
297                let n = self.param(0, 1);
298                self.cursor_y = self.cursor_y.saturating_sub(n);
299                self.state = ParserState::Ground;
300            }
301            b'B' => {
302                self.csi_params.push(self.csi_current);
303                let n = self.param(0, 1);
304                self.cursor_y = (self.cursor_y + n).min(self.height.saturating_sub(1));
305                self.state = ParserState::Ground;
306            }
307            b'C' => {
308                self.csi_params.push(self.csi_current);
309                let n = self.param(0, 1);
310                self.cursor_x = (self.cursor_x + n).min(self.width.saturating_sub(1));
311                self.state = ParserState::Ground;
312            }
313            b'D' => {
314                self.csi_params.push(self.csi_current);
315                let n = self.param(0, 1);
316                self.cursor_x = self.cursor_x.saturating_sub(n);
317                self.state = ParserState::Ground;
318            }
319            b'H' | b'f' => {
320                // CUP: Cursor Position
321                self.csi_params.push(self.csi_current);
322                let row = self.param(0, 1);
323                let col = self.param(1, 1);
324                self.cursor_y = row.saturating_sub(1).min(self.height.saturating_sub(1));
325                self.cursor_x = col.saturating_sub(1).min(self.width.saturating_sub(1));
326                self.state = ParserState::Ground;
327            }
328            b'J' => {
329                // ED: Erase Display
330                self.csi_params.push(self.csi_current);
331                let mode = match self.param(0, 0) {
332                    0 => EraseMode::ToEnd,
333                    1 => EraseMode::ToStart,
334                    _ => EraseMode::All,
335                };
336                self.erase_display(mode);
337                self.state = ParserState::Ground;
338            }
339            b'K' => {
340                // EL: Erase Line
341                self.csi_params.push(self.csi_current);
342                let mode = match self.param(0, 0) {
343                    0 => EraseMode::ToEnd,
344                    1 => EraseMode::ToStart,
345                    _ => EraseMode::All,
346                };
347                self.erase_line(mode);
348                self.state = ParserState::Ground;
349            }
350            b'm' => {
351                // SGR: Select Graphic Rendition
352                self.csi_params.push(self.csi_current);
353                self.apply_sgr();
354                self.state = ParserState::Ground;
355            }
356            b'h' | b'l' | b's' | b'u' => {
357                // Mode set/reset, save/restore cursor - ignore
358                self.state = ParserState::Ground;
359            }
360            _ => {
361                // Unknown CSI final byte
362                self.state = ParserState::Ground;
363            }
364        }
365    }
366
367    fn osc(&mut self, byte: u8) {
368        match byte {
369            0x07 => {
370                // BEL terminates OSC
371                self.process_osc();
372                self.state = ParserState::Ground;
373            }
374            0x1b => {
375                // ESC might be followed by \ to terminate OSC (ST)
376                self.process_osc();
377                self.state = ParserState::Escape;
378            }
379            _ => {
380                self.osc_buffer.push(byte);
381            }
382        }
383    }
384
385    fn process_osc(&mut self) {
386        let osc_str = String::from_utf8_lossy(&self.osc_buffer).to_string();
387        // OSC 8 ; params ; url ST  - hyperlinks
388        if let Some(rest) = osc_str.strip_prefix("8;") {
389            // Find the URL after the params
390            if let Some((_params, url)) = rest.split_once(';') {
391                if url.is_empty() {
392                    self.current_link = None;
393                } else {
394                    self.current_link = Some(url.to_string());
395                }
396            }
397        }
398    }
399
400    fn param(&self, index: usize, default: u16) -> u16 {
401        self.csi_params.get(index).copied().unwrap_or(default)
402    }
403
404    fn put_char(&mut self, ch: char) {
405        let x = self.cursor_x as usize;
406        let y = self.cursor_y as usize;
407        if y < self.grid.len() && x < self.grid[y].len() {
408            self.grid[y][x] = ModelCell {
409                ch,
410                style: self.current_style.clone(),
411                link: self.current_link.clone(),
412            };
413        }
414        self.cursor_x += 1;
415        if self.cursor_x >= self.width {
416            self.cursor_x = 0;
417            if self.cursor_y + 1 < self.height {
418                self.cursor_y += 1;
419            }
420        }
421    }
422
423    fn erase_line(&mut self, mode: EraseMode) {
424        let y = self.cursor_y as usize;
425        if y >= self.grid.len() {
426            return;
427        }
428        let (start, end) = match mode {
429            EraseMode::ToEnd => (self.cursor_x as usize, self.width as usize),
430            EraseMode::ToStart => (0, self.cursor_x as usize + 1),
431            EraseMode::All => (0, self.width as usize),
432        };
433        for x in start..end.min(self.grid[y].len()) {
434            self.grid[y][x] = ModelCell::default();
435        }
436    }
437
438    fn erase_display(&mut self, mode: EraseMode) {
439        match mode {
440            EraseMode::ToEnd => {
441                // Erase from cursor to end of screen
442                self.erase_line(EraseMode::ToEnd);
443                for y in (self.cursor_y + 1) as usize..self.height as usize {
444                    for cell in &mut self.grid[y] {
445                        *cell = ModelCell::default();
446                    }
447                }
448            }
449            EraseMode::ToStart => {
450                // Erase from start of screen to cursor
451                for y in 0..self.cursor_y as usize {
452                    for cell in &mut self.grid[y] {
453                        *cell = ModelCell::default();
454                    }
455                }
456                self.erase_line(EraseMode::ToStart);
457            }
458            EraseMode::All => {
459                for row in &mut self.grid {
460                    for cell in row {
461                        *cell = ModelCell::default();
462                    }
463                }
464            }
465        }
466    }
467
468    fn apply_sgr(&mut self) {
469        if self.csi_params.is_empty() || (self.csi_params.len() == 1 && self.csi_params[0] == 0) {
470            self.current_style = ModelStyle::default();
471            return;
472        }
473
474        let mut i = 0;
475        while i < self.csi_params.len() {
476            match self.csi_params[i] {
477                0 => self.current_style = ModelStyle::default(),
478                1 => self.current_style.bold = true,
479                2 => self.current_style.dim = true,
480                3 => self.current_style.italic = true,
481                4 => self.current_style.underline = true,
482                5 => self.current_style.blink = true,
483                7 => self.current_style.reverse = true,
484                9 => self.current_style.strikethrough = true,
485                22 => {
486                    self.current_style.bold = false;
487                    self.current_style.dim = false;
488                }
489                23 => self.current_style.italic = false,
490                24 => self.current_style.underline = false,
491                25 => self.current_style.blink = false,
492                27 => self.current_style.reverse = false,
493                29 => self.current_style.strikethrough = false,
494                38 => {
495                    // Foreground: 38;2;r;g;b
496                    if i + 4 < self.csi_params.len() && self.csi_params[i + 1] == 2 {
497                        self.current_style.fg = Some(Rgb::new(
498                            self.csi_params[i + 2] as u8,
499                            self.csi_params[i + 3] as u8,
500                            self.csi_params[i + 4] as u8,
501                        ));
502                        i += 4;
503                    }
504                }
505                39 => self.current_style.fg = None,
506                48 => {
507                    // Background: 48;2;r;g;b
508                    if i + 4 < self.csi_params.len() && self.csi_params[i + 1] == 2 {
509                        self.current_style.bg = Some(Rgb::new(
510                            self.csi_params[i + 2] as u8,
511                            self.csi_params[i + 3] as u8,
512                            self.csi_params[i + 4] as u8,
513                        ));
514                        i += 4;
515                    }
516                }
517                49 => self.current_style.bg = None,
518                _ => {}
519            }
520            i += 1;
521        }
522    }
523}
524
525/// Difference between expected and actual cell.
526#[derive(Debug, Clone)]
527pub struct CellDiff {
528    pub x: u16,
529    pub y: u16,
530    pub expected: ModelCell,
531    pub actual: ModelCell,
532}
533
534impl std::fmt::Display for CellDiff {
535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536        write!(
537            f,
538            "({}, {}): expected '{}' got '{}'",
539            self.x, self.y, self.expected.ch, self.actual.ch
540        )
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[test]
549    fn new_model_empty() {
550        let m = TerminalModel::new(10, 5);
551        assert_eq!(m.width(), 10);
552        assert_eq!(m.height(), 5);
553        assert_eq!(m.cursor(), (0, 0));
554        assert_eq!(m.char_at(0, 0), ' ');
555    }
556
557    #[test]
558    fn print_text() {
559        let mut m = TerminalModel::new(20, 5);
560        m.feed(b"Hello");
561        assert_eq!(m.char_at(0, 0), 'H');
562        assert_eq!(m.char_at(1, 0), 'e');
563        assert_eq!(m.char_at(2, 0), 'l');
564        assert_eq!(m.char_at(3, 0), 'l');
565        assert_eq!(m.char_at(4, 0), 'o');
566        assert_eq!(m.cursor(), (5, 0));
567    }
568
569    #[test]
570    fn cursor_wraps_at_edge() {
571        let mut m = TerminalModel::new(5, 3);
572        m.feed(b"ABCDE");
573        // After 5 chars in 5-wide terminal, cursor wraps
574        assert_eq!(m.cursor(), (0, 1));
575        assert_eq!(m.char_at(0, 0), 'A');
576        assert_eq!(m.char_at(4, 0), 'E');
577    }
578
579    #[test]
580    fn newline() {
581        let mut m = TerminalModel::new(20, 5);
582        m.feed(b"AB\nCD");
583        assert_eq!(m.char_at(0, 0), 'A');
584        assert_eq!(m.char_at(1, 0), 'B');
585        // LF moves down, cursor_x stays
586        assert_eq!(m.char_at(2, 1), 'C');
587        assert_eq!(m.char_at(3, 1), 'D');
588    }
589
590    #[test]
591    fn carriage_return() {
592        let mut m = TerminalModel::new(20, 5);
593        m.feed(b"Hello\r");
594        assert_eq!(m.cursor(), (0, 0));
595        m.feed(b"World");
596        assert_eq!(m.row_text(0), "World");
597    }
598
599    #[test]
600    fn crlf() {
601        let mut m = TerminalModel::new(20, 5);
602        m.feed(b"Line1\r\nLine2");
603        assert_eq!(m.row_text(0), "Line1");
604        assert_eq!(m.row_text(1), "Line2");
605    }
606
607    #[test]
608    fn cursor_position_cup() {
609        let mut m = TerminalModel::new(20, 10);
610        m.feed(b"\x1b[5;10H");
611        // CUP is 1-indexed
612        assert_eq!(m.cursor(), (9, 4));
613    }
614
615    #[test]
616    fn cursor_position_default() {
617        let mut m = TerminalModel::new(20, 10);
618        m.feed(b"\x1b[H");
619        assert_eq!(m.cursor(), (0, 0));
620    }
621
622    #[test]
623    fn cursor_movement() {
624        let mut m = TerminalModel::new(20, 10);
625        m.feed(b"\x1b[5;10H"); // row 5, col 10
626        m.feed(b"\x1b[2A"); // up 2
627        assert_eq!(m.cursor(), (9, 2));
628        m.feed(b"\x1b[3B"); // down 3
629        assert_eq!(m.cursor(), (9, 5));
630        m.feed(b"\x1b[4C"); // right 4
631        assert_eq!(m.cursor(), (13, 5));
632        m.feed(b"\x1b[2D"); // left 2
633        assert_eq!(m.cursor(), (11, 5));
634    }
635
636    #[test]
637    fn cursor_movement_clamps() {
638        let mut m = TerminalModel::new(10, 5);
639        m.feed(b"\x1b[100A"); // up 100
640        assert_eq!(m.cursor(), (0, 0));
641        m.feed(b"\x1b[100B"); // down 100
642        assert_eq!(m.cursor(), (0, 4));
643        m.feed(b"\x1b[100D"); // left 100
644        assert_eq!(m.cursor(), (0, 4));
645        m.feed(b"\x1b[100C"); // right 100
646        assert_eq!(m.cursor(), (9, 4));
647    }
648
649    #[test]
650    fn sgr_bold() {
651        let mut m = TerminalModel::new(20, 5);
652        m.feed(b"\x1b[1mBold\x1b[0m");
653        assert!(m.style_at(0, 0).bold);
654        assert!(m.style_at(3, 0).bold);
655    }
656
657    #[test]
658    fn sgr_reset() {
659        let mut m = TerminalModel::new(20, 5);
660        m.feed(b"\x1b[1;3mBI\x1b[0mN");
661        assert!(m.style_at(0, 0).bold);
662        assert!(m.style_at(0, 0).italic);
663        assert!(!m.style_at(2, 0).bold);
664        assert!(!m.style_at(2, 0).italic);
665    }
666
667    #[test]
668    fn sgr_truecolor_fg() {
669        let mut m = TerminalModel::new(20, 5);
670        m.feed(b"\x1b[38;2;255;0;128mX\x1b[0m");
671        let style = m.style_at(0, 0);
672        assert_eq!(style.fg, Some(Rgb::new(255, 0, 128)));
673    }
674
675    #[test]
676    fn sgr_truecolor_bg() {
677        let mut m = TerminalModel::new(20, 5);
678        m.feed(b"\x1b[48;2;10;20;30mX\x1b[0m");
679        let style = m.style_at(0, 0);
680        assert_eq!(style.bg, Some(Rgb::new(10, 20, 30)));
681    }
682
683    #[test]
684    fn sgr_combined() {
685        let mut m = TerminalModel::new(20, 5);
686        m.feed(b"\x1b[1;3;4;38;2;255;128;0mX\x1b[0m");
687        let style = m.style_at(0, 0);
688        assert!(style.bold);
689        assert!(style.italic);
690        assert!(style.underline);
691        assert_eq!(style.fg, Some(Rgb::new(255, 128, 0)));
692    }
693
694    #[test]
695    fn sgr_selective_reset() {
696        let mut m = TerminalModel::new(20, 5);
697        m.feed(b"\x1b[1;3mX\x1b[23mY");
698        let x_style = m.style_at(0, 0);
699        assert!(x_style.bold);
700        assert!(x_style.italic);
701        let y_style = m.style_at(1, 0);
702        assert!(y_style.bold);
703        assert!(!y_style.italic);
704    }
705
706    #[test]
707    fn erase_line_to_end() {
708        let mut m = TerminalModel::new(10, 3);
709        m.feed(b"ABCDEFGHIJ");
710        m.feed(b"\x1b[1;4H"); // Position at col 4
711        m.feed(b"\x1b[0K"); // Erase to end
712        assert_eq!(m.row_text(0), "ABC");
713    }
714
715    #[test]
716    fn erase_line_to_start() {
717        let mut m = TerminalModel::new(10, 3);
718        m.feed(b"ABCDEFGHIJ");
719        m.feed(b"\x1b[1;4H"); // Position at col 4
720        m.feed(b"\x1b[1K"); // Erase to start (including cursor)
721        assert_eq!(m.char_at(0, 0), ' ');
722        assert_eq!(m.char_at(1, 0), ' ');
723        assert_eq!(m.char_at(2, 0), ' ');
724        assert_eq!(m.char_at(3, 0), ' ');
725        assert_eq!(m.char_at(4, 0), 'E');
726    }
727
728    #[test]
729    fn erase_line_all() {
730        let mut m = TerminalModel::new(10, 3);
731        m.feed(b"ABCDEFGHIJ");
732        m.feed(b"\x1b[1;4H");
733        m.feed(b"\x1b[2K"); // Erase entire line
734        assert_eq!(m.row_text(0), "");
735    }
736
737    #[test]
738    fn erase_display_to_end() {
739        let mut m = TerminalModel::new(10, 3);
740        m.feed(b"Line1     ");
741        m.feed(b"Line2     ");
742        m.feed(b"Line3     ");
743        m.feed(b"\x1b[2;1H"); // row 2, col 1
744        m.feed(b"\x1b[0J"); // Erase from cursor to end of display
745        assert_eq!(m.row_text(0), "Line1");
746        assert_eq!(m.row_text(1), "");
747        assert_eq!(m.row_text(2), "");
748    }
749
750    #[test]
751    fn erase_display_all() {
752        let mut m = TerminalModel::new(10, 3);
753        m.feed(b"XXXXXXXXXX");
754        m.feed(b"YYYYYYYYYY");
755        m.feed(b"\x1b[2J");
756        assert_eq!(m.screen_text(), "");
757    }
758
759    #[test]
760    fn osc8_hyperlink() {
761        let mut m = TerminalModel::new(30, 3);
762        // OSC 8 ; ; url BEL  text  OSC 8 ; ; BEL
763        m.feed(b"\x1b]8;;https://example.com\x07Link\x1b]8;;\x07");
764        assert_eq!(m.char_at(0, 0), 'L');
765        assert_eq!(m.link_at(0, 0), Some("https://example.com".to_string()));
766        assert_eq!(m.link_at(3, 0), Some("https://example.com".to_string()));
767        // After link close, no link
768        assert_eq!(m.link_at(4, 0), None);
769    }
770
771    #[test]
772    fn osc8_with_st_terminator() {
773        let mut m = TerminalModel::new(30, 3);
774        // OSC 8 ; ; url ESC \ text OSC 8 ; ; ESC \
775        m.feed(b"\x1b]8;;http://test.com\x1b\\Link\x1b]8;;\x1b\\");
776        assert_eq!(m.link_at(0, 0), Some("http://test.com".to_string()));
777    }
778
779    #[test]
780    fn screen_text_trims() {
781        let mut m = TerminalModel::new(10, 3);
782        m.feed(b"Hello");
783        let text = m.screen_text();
784        assert_eq!(text, "Hello");
785    }
786
787    #[test]
788    fn dump_format() {
789        let mut m = TerminalModel::new(5, 2);
790        m.feed(b"Hi");
791        let dump = m.dump();
792        assert!(dump.contains("Hi"));
793        assert!(dump.contains("Cursor:"));
794    }
795
796    #[test]
797    fn tab_stop() {
798        let mut m = TerminalModel::new(20, 3);
799        m.feed(b"A\tB");
800        assert_eq!(m.char_at(0, 0), 'A');
801        assert_eq!(m.char_at(8, 0), 'B');
802    }
803
804    #[test]
805    fn backspace() {
806        let mut m = TerminalModel::new(20, 3);
807        m.feed(b"AB\x08C");
808        // After AB, cursor at 2. BS moves to 1. C overwrites at 1.
809        assert_eq!(m.char_at(0, 0), 'A');
810        assert_eq!(m.char_at(1, 0), 'C');
811    }
812
813    #[test]
814    fn feed_str_convenience() {
815        let mut m = TerminalModel::new(20, 3);
816        m.feed_str("Hello");
817        assert_eq!(m.row_text(0), "Hello");
818    }
819
820    #[test]
821    fn sgr_all_attributes() {
822        let mut m = TerminalModel::new(20, 3);
823        m.feed(b"\x1b[1;2;3;4;5;7;9mX\x1b[0m");
824        let s = m.style_at(0, 0);
825        assert!(s.bold);
826        assert!(s.dim);
827        assert!(s.italic);
828        assert!(s.underline);
829        assert!(s.blink);
830        assert!(s.reverse);
831        assert!(s.strikethrough);
832    }
833
834    #[test]
835    fn sgr_reset_individual() {
836        let mut m = TerminalModel::new(20, 3);
837        m.feed(b"\x1b[1;3;4;9mX\x1b[22;23;24;29mY");
838        let x = m.style_at(0, 0);
839        assert!(x.bold);
840        assert!(x.italic);
841        assert!(x.underline);
842        assert!(x.strikethrough);
843        let y = m.style_at(1, 0);
844        assert!(!y.bold);
845        assert!(!y.italic);
846        assert!(!y.underline);
847        assert!(!y.strikethrough);
848    }
849
850    #[test]
851    fn sgr_default_fg_bg() {
852        let mut m = TerminalModel::new(20, 3);
853        m.feed(b"\x1b[38;2;255;0;0mR\x1b[39mX");
854        assert_eq!(m.style_at(0, 0).fg, Some(Rgb::new(255, 0, 0)));
855        assert_eq!(m.style_at(1, 0).fg, None);
856
857        m.feed(b"\x1b[48;2;0;255;0mG\x1b[49mX");
858        assert_eq!(m.style_at(2, 0).bg, Some(Rgb::new(0, 255, 0)));
859        assert_eq!(m.style_at(3, 0).bg, None);
860    }
861
862    #[test]
863    fn multiple_lines_rendering() {
864        let mut m = TerminalModel::new(20, 5);
865        // Simulate presenter output: position and write each line
866        m.feed(b"\x1b[1;1HLine 1");
867        m.feed(b"\x1b[2;1HLine 2");
868        m.feed(b"\x1b[3;1HLine 3");
869        assert_eq!(m.row_text(0), "Line 1");
870        assert_eq!(m.row_text(1), "Line 2");
871        assert_eq!(m.row_text(2), "Line 3");
872    }
873
874    #[test]
875    fn styled_text_rendering() {
876        let mut m = TerminalModel::new(30, 3);
877        // Red bold text followed by normal
878        m.feed(b"\x1b[1;38;2;255;0;0mERROR\x1b[0m: something");
879        assert!(m.style_at(0, 0).bold);
880        assert_eq!(m.style_at(0, 0).fg, Some(Rgb::new(255, 0, 0)));
881        assert!(!m.style_at(5, 0).bold);
882        assert_eq!(m.style_at(5, 0).fg, None);
883        assert_eq!(m.row_text(0), "ERROR: something");
884    }
885
886    // ─── Edge-case tests (bd-1p1kn) ─────────────────────────────
887
888    #[test]
889    fn cell_diff_display() {
890        let diff = CellDiff {
891            x: 3,
892            y: 7,
893            expected: ModelCell {
894                ch: 'A',
895                style: ModelStyle::default(),
896                link: None,
897            },
898            actual: ModelCell {
899                ch: 'B',
900                style: ModelStyle::default(),
901                link: None,
902            },
903        };
904        let s = format!("{diff}");
905        assert!(s.contains("(3, 7)"));
906        assert!(s.contains("expected 'A'"));
907        assert!(s.contains("got 'B'"));
908    }
909
910    #[test]
911    fn cell_diff_debug_clone() {
912        let diff = CellDiff {
913            x: 0,
914            y: 0,
915            expected: ModelCell::default(),
916            actual: ModelCell::default(),
917        };
918        let debug = format!("{diff:?}");
919        assert!(debug.contains("CellDiff"));
920
921        let cloned = diff.clone();
922        assert_eq!(cloned.x, 0);
923        assert_eq!(cloned.y, 0);
924    }
925
926    #[test]
927    fn rgb_new_and_default() {
928        let rgb = Rgb::new(10, 20, 30);
929        assert_eq!(rgb.r, 10);
930        assert_eq!(rgb.g, 20);
931        assert_eq!(rgb.b, 30);
932
933        let def = Rgb::default();
934        assert_eq!(def, Rgb::new(0, 0, 0));
935    }
936
937    #[test]
938    fn rgb_debug_copy_eq() {
939        let a = Rgb::new(255, 128, 0);
940        let b = a; // Copy
941        assert_eq!(a, b);
942
943        let debug = format!("{a:?}");
944        assert!(debug.contains("Rgb"));
945    }
946
947    #[test]
948    fn model_style_default() {
949        let s = ModelStyle::default();
950        assert!(s.fg.is_none());
951        assert!(s.bg.is_none());
952        assert!(!s.bold);
953        assert!(!s.dim);
954        assert!(!s.italic);
955        assert!(!s.underline);
956        assert!(!s.blink);
957        assert!(!s.reverse);
958        assert!(!s.strikethrough);
959    }
960
961    #[test]
962    fn model_style_debug_clone_eq() {
963        let s = ModelStyle {
964            bold: true,
965            fg: Some(Rgb::new(1, 2, 3)),
966            ..ModelStyle::default()
967        };
968
969        let cloned = s.clone();
970        assert_eq!(s, cloned);
971
972        let debug = format!("{s:?}");
973        assert!(debug.contains("ModelStyle"));
974    }
975
976    #[test]
977    fn model_cell_default() {
978        let c = ModelCell::default();
979        assert_eq!(c.ch, ' ');
980        assert_eq!(c.style, ModelStyle::default());
981        assert!(c.link.is_none());
982    }
983
984    #[test]
985    fn model_cell_debug_clone_eq() {
986        let c = ModelCell {
987            ch: 'X',
988            style: ModelStyle::default(),
989            link: Some("http://test.com".to_string()),
990        };
991        let cloned = c.clone();
992        assert_eq!(c, cloned);
993
994        let debug = format!("{c:?}");
995        assert!(debug.contains("ModelCell"));
996    }
997
998    #[test]
999    fn cursor_wrap_at_bottom_edge() {
1000        let mut m = TerminalModel::new(3, 2);
1001        // Fill entire 3x2 grid: ABC\nDEF
1002        m.feed(b"ABCDE");
1003        // After 3 chars: cursor at (0, 1). After 5: cursor at (2, 1).
1004        assert_eq!(m.cursor(), (2, 1));
1005        // Writing one more: cursor would wrap but is at bottom row
1006        m.feed(b"F");
1007        // Cursor wraps x to 0, but y can't go past height-1
1008        assert_eq!(m.cursor(), (0, 1));
1009        assert_eq!(m.char_at(2, 1), 'F');
1010    }
1011
1012    #[test]
1013    fn lf_at_bottom_of_screen() {
1014        let mut m = TerminalModel::new(10, 2);
1015        m.feed(b"\x1b[2;1H"); // Move to bottom row
1016        assert_eq!(m.cursor(), (0, 1));
1017        m.feed(b"\n"); // LF at bottom should not go past
1018        assert_eq!(m.cursor(), (0, 1));
1019    }
1020
1021    #[test]
1022    fn bs_at_column_zero() {
1023        let mut m = TerminalModel::new(10, 3);
1024        m.feed(b"\x08"); // BS at column 0
1025        assert_eq!(m.cursor(), (0, 0));
1026    }
1027
1028    #[test]
1029    fn tab_near_end_of_line() {
1030        let mut m = TerminalModel::new(10, 3);
1031        m.feed(b"1234567\t"); // At col 7, tab to col 8
1032        assert_eq!(m.cursor(), (8, 0));
1033        m.feed(b"\r12345678\t"); // At col 8, tab would go to col 16, clamped to 9
1034        assert_eq!(m.cursor(), (9, 0));
1035    }
1036
1037    #[test]
1038    fn tab_already_at_end() {
1039        let mut m = TerminalModel::new(8, 3);
1040        m.feed(b"12345678"); // Fill first line, cursor wraps to (0, 1)
1041        m.feed(b"\x1b[1;8H"); // Move to col 8 (which is col 7 zero-indexed)
1042        m.feed(b"\t"); // Tab should clamp to width-1 = 7
1043        assert_eq!(m.cursor().0, 7);
1044    }
1045
1046    #[test]
1047    fn cup_f_variant() {
1048        let mut m = TerminalModel::new(20, 10);
1049        m.feed(b"\x1b[3;5f"); // CUP with 'f' instead of 'H'
1050        assert_eq!(m.cursor(), (4, 2));
1051    }
1052
1053    #[test]
1054    fn cup_clamps_to_screen_bounds() {
1055        let mut m = TerminalModel::new(10, 5);
1056        m.feed(b"\x1b[100;200H");
1057        assert_eq!(m.cursor(), (9, 4));
1058    }
1059
1060    #[test]
1061    fn cup_zero_params_default_to_1_1() {
1062        let mut m = TerminalModel::new(10, 5);
1063        m.feed(b"\x1b[5;5H"); // move to (4, 4)
1064        m.feed(b"\x1b[0;0H"); // 0 params → treated as 1;1 (home)
1065        // 0 is treated as default (1), so cursor should be at (0, 0)
1066        assert_eq!(m.cursor(), (0, 0));
1067    }
1068
1069    #[test]
1070    fn erase_display_to_start() {
1071        let mut m = TerminalModel::new(10, 3);
1072        m.feed(b"Line1     ");
1073        m.feed(b"Line2     ");
1074        m.feed(b"Line3     ");
1075        m.feed(b"\x1b[2;5H"); // row 2, col 5 (0-indexed: y=1, x=4)
1076        m.feed(b"\x1b[1J"); // Erase from start of display to cursor
1077
1078        // Row 0 should be erased
1079        assert_eq!(m.row_text(0), "");
1080        // Row 1: columns 0-4 erased (ToStart includes cursor pos)
1081        assert_eq!(m.char_at(0, 1), ' ');
1082        assert_eq!(m.char_at(3, 1), ' ');
1083        assert_eq!(m.char_at(4, 1), ' ');
1084        // Row 2 untouched
1085        assert_eq!(m.row_text(2), "Line3");
1086    }
1087
1088    #[test]
1089    fn sgr_truecolor_insufficient_params_fg() {
1090        let mut m = TerminalModel::new(10, 3);
1091        // 38;2 without enough r;g;b params
1092        m.feed(b"\x1b[38;2;255mX");
1093        // Should not set fg (insufficient params)
1094        assert!(m.style_at(0, 0).fg.is_none());
1095    }
1096
1097    #[test]
1098    fn sgr_truecolor_insufficient_params_bg() {
1099        let mut m = TerminalModel::new(10, 3);
1100        // 48;2 without enough r;g;b params
1101        m.feed(b"\x1b[48;2mX");
1102        assert!(m.style_at(0, 0).bg.is_none());
1103    }
1104
1105    #[test]
1106    fn sgr_empty_is_reset() {
1107        let mut m = TerminalModel::new(10, 3);
1108        m.feed(b"\x1b[1mA\x1b[mB"); // \x1b[m with no params = reset
1109        assert!(m.style_at(0, 0).bold);
1110        assert!(!m.style_at(1, 0).bold);
1111    }
1112
1113    #[test]
1114    fn sgr_unknown_code_ignored() {
1115        let mut m = TerminalModel::new(10, 3);
1116        m.feed(b"\x1b[1;99;3mX"); // 99 is unknown, should be ignored
1117        let s = m.style_at(0, 0);
1118        assert!(s.bold);
1119        assert!(s.italic);
1120    }
1121
1122    #[test]
1123    fn multi_byte_utf8_treated_as_question() {
1124        let mut m = TerminalModel::new(10, 3);
1125        m.feed(&[0xC3, 0xA9]); // 'é' in UTF-8
1126        // First byte 0xC3 triggers put_char('?'), second byte 0xA9 is >0x7e, ignored
1127        assert_eq!(m.char_at(0, 0), '?');
1128    }
1129
1130    #[test]
1131    fn char_at_out_of_bounds() {
1132        let m = TerminalModel::new(5, 3);
1133        // Out of bounds returns ' ' (default)
1134        assert_eq!(m.char_at(10, 0), ' ');
1135        assert_eq!(m.char_at(0, 10), ' ');
1136        assert_eq!(m.char_at(100, 100), ' ');
1137    }
1138
1139    #[test]
1140    fn style_at_out_of_bounds() {
1141        let m = TerminalModel::new(5, 3);
1142        let s = m.style_at(100, 100);
1143        assert_eq!(s, ModelStyle::default());
1144    }
1145
1146    #[test]
1147    fn link_at_out_of_bounds() {
1148        let m = TerminalModel::new(5, 3);
1149        assert!(m.link_at(100, 100).is_none());
1150    }
1151
1152    #[test]
1153    fn row_text_out_of_bounds() {
1154        let m = TerminalModel::new(5, 3);
1155        assert_eq!(m.row_text(100), "");
1156    }
1157
1158    #[test]
1159    fn screen_text_all_empty() {
1160        let m = TerminalModel::new(5, 3);
1161        assert_eq!(m.screen_text(), "");
1162    }
1163
1164    #[test]
1165    fn screen_text_trailing_empty_lines_trimmed() {
1166        let mut m = TerminalModel::new(10, 5);
1167        m.feed(b"Hello");
1168        m.feed(b"\x1b[2;1HWorld");
1169        let text = m.screen_text();
1170        assert_eq!(text, "Hello\nWorld");
1171    }
1172
1173    #[test]
1174    fn unknown_escape_sequence_returns_to_ground() {
1175        let mut m = TerminalModel::new(10, 3);
1176        m.feed(b"\x1b)A"); // Unknown escape char ')'
1177        // Should return to ground and write 'A'
1178        assert_eq!(m.char_at(0, 0), 'A');
1179    }
1180
1181    #[test]
1182    fn unknown_csi_final_byte_returns_to_ground() {
1183        let mut m = TerminalModel::new(10, 3);
1184        m.feed(b"\x1b[5ZA"); // 'Z' is unknown CSI final byte
1185        // Should return to ground, then write 'A'
1186        assert_eq!(m.char_at(0, 0), 'A');
1187    }
1188
1189    #[test]
1190    fn csi_private_mode_prefix_ignored() {
1191        let mut m = TerminalModel::new(10, 3);
1192        // DEC private mode: CSI ? 2026 h (synchronized output)
1193        m.feed(b"\x1b[?2026hA");
1194        // Should not affect cursor or state; 'A' written after
1195        assert_eq!(m.char_at(0, 0), 'A');
1196    }
1197
1198    #[test]
1199    fn csi_save_restore_cursor_ignored() {
1200        let mut m = TerminalModel::new(10, 3);
1201        m.feed(b"AB");
1202        m.feed(b"\x1b[s"); // Save cursor (ignored)
1203        m.feed(b"CD");
1204        m.feed(b"\x1b[u"); // Restore cursor (ignored)
1205        m.feed(b"EF");
1206        // Since save/restore is ignored, cursor just continues
1207        assert_eq!(m.row_text(0), "ABCDEF");
1208    }
1209
1210    #[test]
1211    fn osc8_link_toggle() {
1212        let mut m = TerminalModel::new(30, 3);
1213        // Link on, write, link off, write, different link on, write
1214        m.feed(b"\x1b]8;;http://a.com\x07A\x1b]8;;\x07B\x1b]8;;http://b.com\x07C\x1b]8;;\x07");
1215        assert_eq!(m.link_at(0, 0), Some("http://a.com".to_string()));
1216        assert!(m.link_at(1, 0).is_none());
1217        assert_eq!(m.link_at(2, 0), Some("http://b.com".to_string()));
1218    }
1219
1220    #[test]
1221    fn cr_lf_sequence() {
1222        let mut m = TerminalModel::new(10, 5);
1223        m.feed(b"ABC\r\nDEF\r\nGHI");
1224        assert_eq!(m.row_text(0), "ABC");
1225        assert_eq!(m.row_text(1), "DEF");
1226        assert_eq!(m.row_text(2), "GHI");
1227    }
1228
1229    #[test]
1230    fn multiple_backspaces() {
1231        let mut m = TerminalModel::new(10, 3);
1232        m.feed(b"ABCDE\x08\x08\x08XY");
1233        // After ABCDE cursor at 5. 3 BS → cursor at 2. XY overwrites at 2,3.
1234        assert_eq!(m.row_text(0), "ABXYE");
1235    }
1236
1237    #[test]
1238    fn cursor_movement_explicit_one() {
1239        let mut m = TerminalModel::new(20, 10);
1240        m.feed(b"\x1b[5;10H"); // row 5, col 10 → (9, 4)
1241        m.feed(b"\x1b[1A"); // up 1
1242        assert_eq!(m.cursor(), (9, 3));
1243        m.feed(b"\x1b[1B"); // down 1
1244        assert_eq!(m.cursor(), (9, 4));
1245        m.feed(b"\x1b[1C"); // right 1
1246        assert_eq!(m.cursor(), (10, 4));
1247        m.feed(b"\x1b[1D"); // left 1
1248        assert_eq!(m.cursor(), (9, 4));
1249    }
1250
1251    #[test]
1252    fn cursor_movement_no_param_is_zero() {
1253        // In this model, CSI A without digits pushes csi_current=0,
1254        // so param(0, 1) returns 0 (not default 1). This is a
1255        // simplification vs real terminals which treat 0 as 1.
1256        let mut m = TerminalModel::new(20, 10);
1257        m.feed(b"\x1b[5;10H"); // (9, 4)
1258        m.feed(b"\x1b[A"); // no digits → n=0, cursor stays
1259        assert_eq!(m.cursor(), (9, 4));
1260    }
1261
1262    #[test]
1263    fn sgr_22_resets_both_bold_and_dim() {
1264        let mut m = TerminalModel::new(10, 3);
1265        m.feed(b"\x1b[1;2mA\x1b[22mB");
1266        let a = m.style_at(0, 0);
1267        assert!(a.bold);
1268        assert!(a.dim);
1269        let b = m.style_at(1, 0);
1270        assert!(!b.bold);
1271        assert!(!b.dim);
1272    }
1273
1274    #[test]
1275    fn sgr_blink_and_reverse_reset() {
1276        let mut m = TerminalModel::new(10, 3);
1277        m.feed(b"\x1b[5;7mA\x1b[25;27mB");
1278        let a = m.style_at(0, 0);
1279        assert!(a.blink);
1280        assert!(a.reverse);
1281        let b = m.style_at(1, 0);
1282        assert!(!b.blink);
1283        assert!(!b.reverse);
1284    }
1285
1286    #[test]
1287    fn erase_line_at_row_zero() {
1288        let mut m = TerminalModel::new(10, 3);
1289        m.feed(b"ABCDEFGHIJ");
1290        m.feed(b"\x1b[1;1H\x1b[2K");
1291        assert_eq!(m.row_text(0), "");
1292    }
1293
1294    #[test]
1295    fn erase_display_all_preserves_cursor() {
1296        let mut m = TerminalModel::new(10, 3);
1297        m.feed(b"XXXXXXXXXX");
1298        m.feed(b"\x1b[1;5H"); // cursor at (4, 0)
1299        m.feed(b"\x1b[2J");
1300        assert_eq!(m.screen_text(), "");
1301        // Cursor position not reset by ED
1302        assert_eq!(m.cursor(), (4, 0));
1303    }
1304
1305    #[test]
1306    fn feed_empty_bytes() {
1307        let mut m = TerminalModel::new(10, 3);
1308        m.feed(b"");
1309        assert_eq!(m.cursor(), (0, 0));
1310        assert_eq!(m.screen_text(), "");
1311    }
1312
1313    #[test]
1314    fn feed_str_empty() {
1315        let mut m = TerminalModel::new(10, 3);
1316        m.feed_str("");
1317        assert_eq!(m.cursor(), (0, 0));
1318    }
1319
1320    #[test]
1321    fn put_char_at_full_grid_bottom_right() {
1322        let mut m = TerminalModel::new(3, 2);
1323        // Position at last cell
1324        m.feed(b"\x1b[2;3H"); // row 2, col 3 → (2, 1)
1325        m.feed(b"Z");
1326        assert_eq!(m.char_at(2, 1), 'Z');
1327        // Cursor wraps but stays at bottom
1328        assert_eq!(m.cursor(), (0, 1));
1329    }
1330
1331    #[test]
1332    fn control_chars_ignored() {
1333        let mut m = TerminalModel::new(10, 3);
1334        // Various control chars that should be ignored (not 0x08, 0x09, 0x0a, 0x0d, 0x1b)
1335        m.feed(&[0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x0b, 0x0c]);
1336        // Cursor should not have moved
1337        assert_eq!(m.cursor(), (0, 0));
1338        assert_eq!(m.screen_text(), "");
1339    }
1340
1341    #[test]
1342    fn printable_ascii_range() {
1343        let mut m = TerminalModel::new(95, 1);
1344        // All printable ASCII: 0x20 to 0x7e
1345        let printable: Vec<u8> = (0x20..=0x7eu8).collect();
1346        m.feed(&printable);
1347        assert_eq!(m.char_at(0, 0), ' ');
1348        assert_eq!(m.char_at(94, 0), '~');
1349    }
1350
1351    #[test]
1352    fn dump_shows_cursor_and_style() {
1353        let mut m = TerminalModel::new(5, 2);
1354        m.feed(b"\x1b[1mBold\x1b[0m");
1355        let dump = m.dump();
1356        assert!(dump.contains("Bold"));
1357        assert!(dump.contains("Cursor:"));
1358        assert!(dump.contains("Style:"));
1359    }
1360
1361    #[test]
1362    fn multiple_sgr_sequences_accumulate() {
1363        let mut m = TerminalModel::new(10, 3);
1364        m.feed(b"\x1b[1m\x1b[3m\x1b[4mX");
1365        let s = m.style_at(0, 0);
1366        assert!(s.bold);
1367        assert!(s.italic);
1368        assert!(s.underline);
1369    }
1370
1371    #[test]
1372    fn sgr_zero_in_middle_resets_all() {
1373        let mut m = TerminalModel::new(10, 3);
1374        m.feed(b"\x1b[1;0;3mX");
1375        // SGR 1 sets bold, SGR 0 resets, SGR 3 sets italic
1376        let s = m.style_at(0, 0);
1377        assert!(!s.bold);
1378        assert!(s.italic);
1379    }
1380
1381    #[test]
1382    fn width_1_terminal() {
1383        let mut m = TerminalModel::new(1, 3);
1384        m.feed(b"ABC");
1385        assert_eq!(m.char_at(0, 0), 'A');
1386        assert_eq!(m.char_at(0, 1), 'B');
1387        assert_eq!(m.char_at(0, 2), 'C');
1388    }
1389
1390    #[test]
1391    fn height_1_terminal() {
1392        let mut m = TerminalModel::new(10, 1);
1393        m.feed(b"Hello");
1394        assert_eq!(m.row_text(0), "Hello");
1395        m.feed(b"\n"); // LF at bottom, should not move
1396        assert_eq!(m.cursor(), (5, 0));
1397    }
1398}