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