1#![forbid(unsafe_code)]
2
3use crate::terminal_model::TerminalModel;
32use std::fmt;
33use std::io;
34use std::path::Path;
35
36#[derive(Debug)]
41pub struct HeadlessTerm {
42 model: TerminalModel,
43 captured_output: Vec<u8>,
44}
45
46impl HeadlessTerm {
47 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 pub fn width(&self) -> u16 {
63 self.model.width() as u16
64 }
65
66 pub fn height(&self) -> u16 {
68 self.model.height() as u16
69 }
70
71 pub fn cursor(&self) -> (u16, u16) {
73 let (x, y) = self.model.cursor();
74 (x as u16, y as u16)
75 }
76
77 pub fn process(&mut self, bytes: &[u8]) {
82 self.captured_output.extend_from_slice(bytes);
83 self.model.process(bytes);
84 }
85
86 pub fn row_text(&self, row: usize) -> String {
90 self.model.row_text(row).unwrap_or_default()
91 }
92
93 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 pub fn screen_string(&self) -> String {
102 self.screen_text().join("\n")
103 }
104
105 pub fn model(&self) -> &TerminalModel {
107 &self.model
108 }
109
110 pub fn captured_output(&self) -> &[u8] {
112 &self.captured_output
113 }
114
115 pub fn reset(&mut self) {
117 self.model.reset();
118 self.captured_output.clear();
119 }
120
121 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 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 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 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 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 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#[derive(Debug, Clone)]
293pub struct LineDiff {
294 pub line: usize,
296 pub got: String,
298 pub want: String,
300}
301
302#[derive(Debug, Clone)]
304pub struct ScreenDiff {
305 pub actual_lines: usize,
307 pub expected_lines: usize,
309 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 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"); 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(&["", ""]); }
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); }
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 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 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 assert_eq!(term.row_text(0), "Bold Red");
547
548 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"); term.process(b"\x1b[2J"); 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 #[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 let prev = Buffer::new(10, 3);
642 let mut next = Buffer::new(10, 3);
643
644 for (i, ch) in "Hello".chars().enumerate() {
646 next.set(i as u16, 0, Cell::from_char(ch));
647 }
648
649 let diff = BufferDiff::compute(&prev, &next);
651
652 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); buf
660 };
661
662 let mut term = HeadlessTerm::new(10, 3);
664 term.process(&output);
665
666 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 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 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 #[test]
721 fn cursor_move_up() {
722 let mut term = HeadlessTerm::new(20, 10);
723 term.process(b"\x1b[5;5H"); term.assert_cursor(4, 4);
725 term.process(b"\x1b[2A"); 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"); term.assert_cursor(0, 0);
734 term.process(b"\x1b[3B"); 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"); term.assert_cursor(0, 0);
743 term.process(b"\x1b[5C"); 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"); term.assert_cursor(9, 0);
752 term.process(b"\x1b[4D"); term.assert_cursor(5, 0);
754 }
755
756 #[test]
757 fn cursor_move_default_count() {
758 let mut term = HeadlessTerm::new(20, 10);
760 term.process(b"\x1b[5;5H"); term.process(b"\x1b[A"); term.assert_cursor(4, 3);
763 term.process(b"\x1b[C"); term.assert_cursor(5, 3);
765 term.process(b"\x1b[B"); term.assert_cursor(5, 4);
767 term.process(b"\x1b[D"); 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"); term.process(b"\x1b[3C"); term.process(b"\x1b[2B"); term.process(b"\x1b[1D"); term.process(b"\x1b[1A"); 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"); term.process(b"\x1b[99A"); term.assert_cursor(0, 0); }
789
790 #[test]
791 fn cursor_clamped_at_left() {
792 let mut term = HeadlessTerm::new(20, 10);
793 term.process(b"\x1b[1;1H"); term.process(b"\x1b[99D"); term.assert_cursor(0, 0); }
797
798 #[test]
799 fn cursor_clamped_at_bottom() {
800 let mut term = HeadlessTerm::new(20, 10);
801 term.process(b"\x1b[10;1H"); term.process(b"\x1b[99B"); 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"); term.process(b"\x1b[99C"); 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"); term.process(b"\x1b[8G"); 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"); term.process(b"\x1b[6d"); 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"); term.process(b"ABC");
837 term.process(b"\x1b[2A"); term.process(b"XY");
839 term.assert_row(0, " XY");
840 term.assert_row(2, "ABC");
841 }
842}